Module 15 — Concepts and Explanations¶
The library system works. It has encapsulated types, an interface for items, a repository, honest ownership, designed failure paths, and queries written as library algorithms with measured costs. Every module so far has asked is this correct? or is this efficient?
This one asks a different question: is this well designed? — and to answer it you need criteria, a notation to communicate with, and a vocabulary. That is what the five SOLID principles, UML, and the design patterns provide.
Change localisation is the criterion. A design is good if the next requirement lands in one place; it is bad if the same requirement touches eleven files. Everything else in this module — UML, principles, patterns — is in service of that one measurement.
Two cautions shape this page, and they are the reason it ends where it does. Patterns are vocabulary, not goals: reaching for one before there is a problem produces the abstract, unreadable code that gives design patterns a bad name. And the principles pull against each other in practice — more interfaces means more indirection; openness means more to read through. Evaluation means naming the cost of the choice you made.
Every version of every design in this page is in examples/module-15/, with the change cost printed.
1. Where would I change the code if this requirement arrived?¶
Start with a requirement, not a principle:
"Fines should depend on the member's category as well as the item's kind."
Then count. In the Lab 14 system, that requirement touches: the fine rule, the places that check the limit, the report's formatting, and the tests that assert on totals — four places, three of them unrelated to each other. That number is the design finding. It is not a coding mistake: every one of those places is correct today. It is a design result, and it is what the rest of this module is about reducing.
what makes the requirement expensive? the principle it violates
──────────────────────────────────────────────────────────── ──────────────────────────
one class holds the rule, the storage and the reporting single responsibility
a switch on the kind appears in three functions open/closed
a derived type cannot honour an inherited operation Liskov substitution
an implementer must write five stubs interface segregation
a policy constructs the class it depends on dependency inversion
Read that table as a diagnostic tool rather than a set of virtues: each row is a symptom you can see in code, and each leads to a change that is local.
2. How do I draw what I have?¶
UML class notation, reduced to what this course needs:
┌─────────────────────────┐
│ Catalogue │ the class box: name, then its members
├─────────────────────────┤
│ - items_ : vector<Item> │ '-' private, '#' protected, '+' public
│ - name_ : string │
├─────────────────────────┤
│ + add(item : Item) │ operations, with parameter types
│ + find(id : int) : Item │
│ + size() : size_t │
└─────────────────────────┘
Four relationships, and the notation encodes Module 9's ownership exactly:
| Relationship | Notation | Means |
|---|---|---|
| Dependency | A ┈┈> B |
A uses B in an operation's parameter or body, and does not store it |
| Association | A ────> B |
A holds a reference or pointer to B, with no ownership |
| Aggregation | A ◇───> B |
A holds B, and B can outlive A |
| Composition | A ◆───> B |
A owns B; B's lifetime is A's |
| Inheritance | B ──▷ A |
B is a kind of A (hollow triangle, pointing at the base) |
Two practices that make the diagram useful rather than decorative:
- Draw it from the code, not from the intention. If
Cataloguestoresvector<Item>by value, that is composition; if it storesvector<shared_ptr<Item>>, the diamond is hollow and the lifetime question is now somebody else's. - Mark the multiplicities (
1,0..*). "A library has items" and "a library has exactly one catalogue" are different designs, and the diagram is where the difference becomes visible.
3. How do I know when a class does too much?¶
State its responsibility in one sentence. If the sentence needs "and", there are two responsibilities:
LoanService -> "It lends items, computes fines AND prints reports."
three clauses, three audiences, one file:
a librarian changes the fine rule;
an administrator changes the report layout;
the IT office changes how loans are recorded.
The test is not "is the class small?". It is who would change it, and why? Three audiences with three reasons to open the file is the finding; the size is a symptom.
examples/module-15/01-one-responsibility-per-class/main.cpp splits that class three ways and prints
the same report from both versions, line for line, so the refactoring is demonstrated rather than
asserted — and then prints the honest cost:
One file in both cases - and that is the honest version of the claim.
The split does not reduce the COUNT here; it reduces what is at risk inside
the file.
When the split is wrong: when the two clauses have the same audience and the same rate of change. Three classes to read, construct and pass is real work. Split when the sentence needs "and" and the clauses change for different reasons — otherwise write the sentence down and leave the class alone.
4. Adding a new kind of item means editing this switch¶
That is the open/closed question, and it is about where the variation lives:
// the kind is DATA, so every operation asks about it
switch (kind) {
case ItemKind::Book: return 21;
case ItemKind::Dvd: return 7;
case ItemKind::Equipment: return 1;
}
The program counts the sites for a fourth kind: the enumerator, and a new case in each of three
functions — four edits in one file, and a rule ("edit every switch") that somebody has to remember
forever. After the refactoring, the same fourth kind is one new class and one registration line, with
zero edits to the existing classes:
Change inventory for the same fourth kind:
Equipment one new class implementing Lendable
0 edits to Book, Dvd, or any caller of them
1 edit where new kinds are registered - the factory (next example)
Closed for modification means closed with respect to what varies. The polymorphic version is closed for new kinds and open for new operations: adding an operation that depends on the kind means editing the interface and every implementation. That is not a flaw; it is the axis you chose. Choose it from the requirements — which axis has actually been changing? — not from a principle.
5. My derived type breaks the base's promise¶
Inheritance is for substitution, not for reuse. The failure appears in client code that used the base correctly:
class AnythingLendable {
public:
virtual int loanPeriodDays() const = 0; // promises EVERY item can be lent
};
class ReferenceCopy : public AnythingLendable {
public:
int loanPeriodDays() const override {
throw std::logic_error("reference copies are read in the library, never lent");
}
};
int totalReminderDays(const std::vector<AnythingLendable*>& items); // breaks on the reference copy
A client written against the base type is handed both items:
threw: reference copies are read in the library, never lent
Who is at fault? Not the client: it used only what the base promised. Not really the derived class:
a reference copy genuinely cannot be lent. The fault is in the abstraction — AnythingLendable
claims every item can be lent, and that was never true.
Liskov substitution, stated as three promises the derived class must keep:
- Preconditions cannot be strengthened — it must not demand more than the base allowed.
- Postconditions cannot be weakened — it must deliver at least what the base promised.
- Invariants must be preserved — whatever the base guaranteed stays true.
The fix is a better abstraction, not a better override: split the promise into a capability that only the types which can honour it implement.
class CatalogueItem { virtual std::string title() const = 0; };
class LendableCapability { virtual int loanPeriodDays() const = 0; };
class LendableBook : public CatalogueItem, public LendableCapability { /* ... */ };
class ReferenceOnly : public CatalogueItem { /* not lendable */ };
The client's parameter becomes const std::vector<LendableCapability*>&, and the wrong substitution
becomes a compile error instead of a run-time throw — which the example program checks with
static_assert:
static_assert(!std::is_base_of<LendableCapability, ReferenceOnly>::value,
"a reference copy is not lendable, and the type system says so");
The rule that generalises: when a derived type cannot honour an inherited operation, the operation is in the wrong interface. Move it to a capability that only the types that can honour it implement.
6. Implementers must write five functions, and need two¶
Interface segregation is the principle students find most abstract until they have written the stub:
class LibraryService { // six operations, three audiences
public:
virtual void addItem(const std::string&) = 0;
virtual std::vector<std::string> allTitles() const = 0;
virtual void lend(const std::string&) = 0;
virtual void giveBack(const std::string&) = 0;
virtual void emailMember(const std::string&) = 0;
virtual void printReceipt(const std::string&) = 0;
};
class ReadOnlyCatalogue : public LibraryService {
// 2 operations it can honour, and 4 stubs that throw
};
Two costs, both real:
- Every implementer writes code it cannot honour, and each stub is a throw that fires the day somebody calls it (or worse, a silent no-op).
- Every client depends on all six operations. A change to
emailMember— which a report generator cannot even call — forces that class to be recompiled, re-read and re-tested.
So: many small interfaces, each owned by its users. Three interfaces, no stubs:
what SnapshotCatalogue had to write:
operations it can honour: 2
operations it must stub: 0
interfaces it implements: 1 (ItemCatalogue)
And an implementation that is only a catalogue can no longer be handed to a caller that wants a lending desk — the compiler refuses a combination that would have thrown.
7. My high-level policy depends on a low-level class¶
Dependency inversion has two halves, and the second is the one that matters:
// BEFORE: the policy constructs what it depends on
class LendingPolicyHardCoded {
public:
std::string remind(const std::string& address, int overdueDays) {
ConcreteEmailer emailer; // constructed here, and unreplaceable
emailer.send(address, "...");
return "reminder sent";
}
};
Nothing can intercept what this sends. The policy's logic — when to remind, and what to say — is untestable because of one line of construction. Note that this is not a compile error and not a crash; it is an inability to write a test, which is a design cost expressed as a missing capability.
// AFTER: the policy owns the interface, and the caller injects an implementation
class Notifier { // THE POLICY OWNS THIS
public:
virtual void send(const std::string& address, const std::string& message) = 0;
};
class LendingPolicy {
public:
explicit LendingPolicy(Notifier& notifier) : notifier_(notifier) {}
/* ... */
};
class ConsoleNotifier : public Notifier { /* the real one */ };
class RecordingNotifier : public Notifier { /* the test double */ };
assertions the test can now make:
messages recorded: 2
ayesha@example.edu <- You have an overdue item (3 days)
bilal@example.edu <- You have an overdue item (12 days)
Who owns the interface is the whole principle. If the policy's header must include the notifier's header, the dependency points at the implementation and the implementation is in charge of the policy's interface. If the notifier's header includes the policy's, the dependency is inverted — and a test double becomes possible.
Two checks worth running on any design:
- Does any interface force an implementer to write a stub? (segregation)
- Is each interface owned by the side that needs it, or by the side that happened to exist first? (inversion)
8. The principles pull against each other¶
They do, and pretending otherwise is how design advice becomes unusable. The common tensions:
| Pulling apart | The cost of obeying each one maximally |
|---|---|
| Single responsibility vs simplicity | Every tiny behaviour becomes a type; a reader holds six files to understand eight lines |
| Interface segregation vs single responsibility | Splitting an interface can create two types with one reason between them |
| Open/closed vs readability | Every extension point is indirection; a plain conditional is faster to read than three classes |
| Dependency inversion vs knowing what runs | Injecting everything hides the composition, so somebody must build the graph — the composition root |
| Liskov vs reuse | The convenient base class is usually the one that breaks substitution |
The skill is not maximising them. It is choosing, and stating the cost:
"We kept
FineCalculatorconstructing its own rates because there is exactly one configuration, and we accept that a test cannot vary the rate. If a second configuration appears — a branch with different rates — this becomes an injected dependency, and every construction site changes."
That is a decision with a cost, a condition, and a named trigger for revisiting. examples/module-15/
07-over-abstraction/main.cpp builds the same small requirement twice — once as a function, once
across five types — and writes that paragraph out, including what would reverse the decision.
9. What is a design pattern, really?¶
A named solution to a recurring problem, with a cost.
The name is the real deliverable: it lets five people discuss a design in a sentence. The solution is usually something you would have written anyway — factory is a function that builds objects, strategy is a virtual function chosen by the caller, observer is a list of callbacks.
That has two consequences worth internalising:
- The pattern is a description, not a recipe. Naming what you already built is useful; building something in order to name it is not.
- Every pattern carries a cost, and the cost is always some of: more types, more indirection, a parameter threaded further, a lifetime question relocated. This module's examples state each one, and the module's lab requires one pattern considered and rejected.
10. How do I create items without a wall of new?¶
Item records arrive as text. Without a factory, every reader repeats the construction rules and the validation, slightly differently:
class ItemFactory {
public:
std::string create(const std::string& kind, const std::string& title) const {
if (title.empty()) { throw std::invalid_argument("record rejected: no title"); }
const auto found = registry_.find(kind);
if (found == registry_.end()) {
throw std::invalid_argument("record rejected: unknown kind '" + kind + "'");
}
/* build the right type, and express ownership in the return type */
}
void registerKind(const std::string& kind, std::unique_ptr<CatalogueItemType> type);
};
create("book", "Compilers") -> Compilers [book, 21 days]
create("journal", "Nature") threw: record rejected: unknown kind 'journal'
create("book", "") threw: record rejected: no title
What the factory actually buys, in order of importance:
- One place where creation rules live — validation is written once, so it cannot diverge.
- Ownership in the return type. The factory decides who owns what it creates, and says so:
std::unique_ptr<Item>is a decision a caller cannot ignore. (Returning raw pointers that nobody owns is the most common factory mistake in student code.) - The single edit point for a new kind, which is what makes the open/closed refactoring of section 4 complete: the classes are closed, and the factory is the one place that knows them all.
Factory is the wrong answer when only one kind exists and no requirement suggests a second, or when the "factory" is a class wrapping one constructor call.
11. The lending rule differs by member type¶
Strategy: interchangeable behaviour behind an interface, injected rather than selected by a conditional:
class LendingRule {
public:
virtual int maxItems(const Member&) const = 0;
virtual int loanPeriodDays(const Member&) const = 0;
};
class StandardRules : public LendingRule { /* student/staff/external, in one place */ };
class VacationRules : public LendingRule { /* the August rules, as a class */ };
class Desk {
public:
std::string lend(const Member&, const Item&, const LendingRule& rule) const; // no if/else
};
Dr Majeed borrows "Compilers" for 60 days (rule: standard, limit 20 items)
Dr Majeed borrows "Compilers" for 90 days (rule: vacation, limit 30 items)
The change inventory is the argument: the next rule is a subclass and one line at the composition
root, with zero edits to Desk — because the choice of rule now happens where the context is
known, instead of being inferred inside the operation from a month and a flag.
The cost, stated as the example does: one more type, one more parameter through the call chain, and
a reader who must look at the rule to know what lend does. Strategy is the wrong answer when the
behaviour never varies at run time (a template parameter or a plain function is simpler), or when the
"strategy" carries a single integer.
12. How does everyone find out an item is available?¶
Observer: a subject keeps a list of subscribers and notifies them. The pattern is easy; the question it does not answer is:
Who determines that an observer is still alive?
examples/module-15/05-observer-and-lifetimes/main.cpp answers it explicitly, with weak references
and a snapshot:
void announce(const Item& item) {
const std::vector<Subscription> snapshot = subscriptions_; // a subscriber may leave mid-walk
for (const Subscription& subscription : snapshot) {
if (const std::shared_ptr<Subscriber> alive = subscription.lock()) {
alive->onAvailable(item);
}
}
/* then prune the expired ones */
}
[board] "Compilers": notified 3, pruned 0, subscriptions now 2
...
[board] "Algorithms": notified 1, pruned 1, subscriptions now 1
subscriptions after: 1 - the dead one was pruned, and nothing was called after destruction.
Notice what the second line means: a subscriber was destroyed, the subject was not told, and the next announcement both skipped it and cleaned it up. The lifetime question is answered by the type of reference the subject holds, not by discipline.
The naive version — std::vector<Subscriber*> and subscribe(&local) — is described in the program
and deliberately not run: calling a destroyed object is undefined behaviour, and a demonstration
whose output is undefined is not evidence. The rule that survives from Module 9: the pattern did not
create this bug; it relocated the lifetime question.
The three possible answers, with their costs:
| Who decides the observer is alive | Cost |
|---|---|
| The subject holds weak references and prunes | a lock() per notification; subscribers must be shared_ptr-owned |
| The subscriber unregisters in its destructor (an RAII token) | the subscriber must keep the token; its destructor must not throw |
| The subject owns the subscribers | nobody outside can subscribe — not a plugin, not a test double |
13. Do I need a pattern here?¶
Usually the honest answer is no, and examples/module-15/07-over-abstraction/main.cpp is built to make
that answer respectable. The same requirement — compute a fine — is implemented as a four-parameter
function and as five collaborating types, and the program prints the reader cost of the second:
to answer 'what is the fine for a staff DVD?', version A needs 4 lines of
arithmetic; version B needs the reader to follow a registry, a factory, an
id string built by concatenation, and a class per combination.
Then it names the conditions under which version B is right — configuration-driven rules, separately reviewed rule owners, substitute-able rules for tests, a large sparse combination space — and notes that none of them holds for the requirement at hand.
The test: does the variation exist now, and is it expected to grow? A pattern applied in anticipation is a bet that the future is shaped like the pattern.
14. Was this design good?¶
Evaluation is a decision with the costs named, and it has a shape:
My decision:
The requirement/context it was made for:
The cost of the option I chose:
The option I rejected, and what it would have cost:
What would change my mind:
What makes an evaluation credible, and what makes it a preference:
| Credible | A preference wearing the same words |
|---|---|
| The context is stated first | The design is praised in the abstract |
| The chosen option's cost is named | "It is cleaner" / "it is more flexible" |
| A real alternative is described | A strawman is rejected |
| A condition that would reverse the decision | "This is the right way" |
| Numbers where numbers exist (files touched, types added) | Adjectives |
Every design in this module is evaluated in that form, and Lab 15 requires the last section — one decision you would reverse — because naming a real weakness in your own work is the strongest evidence that the evaluation happened at all.
Checkpoint¶
Answer these before moving on. The section is given for each.
- What is the design criterion this module uses, and how do you measure it? (section 1)
- Draw — on paper — a composition, an aggregation and an association, and say what distinguishes them. (section 2)
- State the single-responsibility test. When is splitting the wrong answer? (section 3)
- What does "closed for modification" mean, and what is its cost? (section 4)
- What are the three promises a derived class must keep? Where does the bug appear when one is broken? (section 5)
- What does interface segregation protect against, and what does obeying it cost? (section 6)
- State dependency inversion, including who owns the interface, and say what testability has to do with it. (section 7)
- Name two SOLID principles that commonly conflict. What do you do when they do? (section 8)
- When is a design pattern the wrong answer? (sections 9 and 13)
- What must an observer design answer besides "how do I notify"? (section 12)
- Name the four parts of a credible design evaluation. (section 14)
Next: Worked examples — seven programs, each written twice: the design that works, and the design that costs less to change.