Design Patterns · Foundations
SOLID Without the Dogma
Five principles that make designs easier to understand, extend, and maintain, with the warning signs each one catches and the cost of overdoing it.
This lesson builds on Design Principles Behind the Patterns. SOLID is an acronym for five principles from Robert C. Martin's Agile Software Development, Principles, Patterns, and Practices. They aim to make designs easier to understand, more flexible, and easier to maintain.
Treat them as warning detectors, not laws. Applying all five everywhere can make a program more complex than the problem. Use the principle that explains a concrete design pressure.
S: Give each class one reason to change
Single Responsibility Principle. A class should have just one reason to change.
Make each class responsible for one part of the program's functionality, and keep that part fully inside the class. The goal is to reduce complexity. Small programs survive anything. The trouble starts as the program grows and a class gets so large that nobody remembers its details. Finding code slows down, and a change to one responsibility risks breaking another one that shares the class.
An Invoice class computes totals. It also renders a PDF and sends email. A designer changing the PDF layout and a developer switching email providers both edit Invoice, and both can break total calculations.
Feeling scattered is the signal
When you struggle to focus on one aspect of a class because other concerns keep getting in the way, it is time to split it.
O: Extend without editing working code
Open/Closed Principle. Classes should be open for extension but closed for modification.
A class is open if you can extend it: subclass it, add behavior, plug in a new variant. It is closed when it is finished and other code depends on its interface, so changing it is risky. The two words sound like opposites, but one class can be both.
A checkout supports card, wallet, and bank transfer payments with a switch on the payment type. Adding a pay-later option means editing Checkout, which already works and already ships. Extract each payment type into a class behind a PaymentMethod interface. Now a new payment type is a new class.
Fix bugs directly
The principle is about new behavior. If a class has a bug, fix the class. Do not create a subclass to work around it. A child class should not be responsible for its parent's defects.
L: Subclasses must keep the parent's promises
Liskov Substitution Principle. When you extend a class, you should be able to pass objects of the subclass in place of objects of the parent without breaking the client code.
A subclass must stay compatible with the behavior of its parent. An override can replace the implementation, but must keep the promises callers rely on. This matters most in libraries and frameworks, whose classes run inside code you cannot see or change.
Unlike the other principles, Liskov comes with concrete checks:
- parameter types in a subclass method should match the parent or be more abstract
- return types should match the parent or be a subtype of it
- a subclass method should not throw exception types the parent method does not throw
- a subclass should not strengthen preconditions, such as rejecting inputs the parent accepted
- a subclass should not weaken postconditions, such as skipping cleanup the parent promised
- a subclass must preserve the parent's invariants, the conditions that make the object valid
- a subclass should not change the parent's private fields, which some languages allow through reflection or no privacy at all
Language rules enforce some signature constraints. Java checks declared checked exceptions. C# and TypeScript do not provide that same exception check. No compiler proves all preconditions, postconditions, or invariants. Review the behavioral contract too.
Try it
Would this override break existing callers?
Safe. The base ship(parcel: Parcel) becomes ship(item: Shippable) in the subclass, where every Parcel is Shippable. Callers still pass parcels, and they still work. Parameter types may stay the same or become more abstract.
Breaks callers. The subclass accepts only FragileParcel. A caller that passes an ordinary parcel now fails. Parameter types must never get more specific.
Safe. The base returns Receipt, the subclass returns EmailReceipt, which is a Receipt. Callers get everything they expect. Return types may stay the same or become more specific.
Breaks callers. The subclass returns Document, which may not be a receipt at all. Callers that read receipt fields fail. Return types follow the opposite rule from parameters.
Breaks callers. Callers wrote try/catch blocks for the exceptions the base method can throw. An unexpected exception type slips past them. Throw only the base exceptions or their subtypes.
Breaks callers. The base adjust(amount: number) accepted any number, so callers pass negative values. The subclass now throws on them. That strengthens a precondition, which subclasses must not do.
Breaks callers. The base export() promised to close every database connection. Callers rely on that and shut down right after. Leaving connections open weakens a postcondition and leaks connections.
Breaks callers. The base Account guarantees balance >= 0. An overdraft subclass breaks that invariant, and code that trusted it can display or spend money that is not there. Invariants are the easiest rule to break, because they are often implicit.
A classic violation: PaymentMethod declares refund(), and GiftCard overrides it to throw NotSupported. Every caller that refunds a payment now has to check for gift cards first. That breaks Liskov, and it breaks open/closed too, because each new non-refundable method forces edits in those callers.
Try it
Redesign the hierarchy so every promise holds
for (const m of payments) m.refund(10)
→ GiftCard.refund(): throws NotSupported
callers add: if (m instanceof GiftCard) …
for (const m of refundables) m.refund(10)
→ only RefundableMethod objects reach this loop
GiftCard never promised a refund
The fix moves a promise down the hierarchy to the classes that can keep it.
I: Keep interfaces small enough to implement fully
Interface Segregation Principle. Clients should not be forced to depend on methods they do not use.
Split fat interfaces into narrow ones, so each class implements only what it really does. Otherwise a change to a method that a class never uses can still break it.
A smart home library starts with one SmartDevice interface: on and off, set temperature, play audio, lock and unlock. A smart bulb must now stub four methods it cannot support. Split the interface. A class can implement several small interfaces at once, even though it can extend only one class.
Every interface you add is one more thing to name and maintain
Do not split interfaces that are already specific. Stop when no implementer carries methods it cannot honor.
D: Point dependencies at abstractions
Dependency Inversion Principle. High-level classes should not depend on low-level classes. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
Low-level classes do basic work: disk access, network calls, database queries. High-level classes hold the business logic that directs them. When business code calls a database driver's API directly, infrastructure changes can force business-code changes too.
- Describe the operations the high-level class needs in business terms:
save(order), notinsertRow(table, values). - Build the high-level class against that interface.
- Make the low-level class implement the interface. It now depends on the business layer's abstraction, and the original dependency is inverted.
Try it
Upgrade the database driver. What has to change?
driver renames query() → execute()
OrderService breaks
business logic changes for a plumbing reason
driver renames query() → execute()
PostgresOrderRepository changes
OrderService is untouched and tests with an in-memory repository
Dependency inversion usually works together with open/closed: you can plug in a different storage class without editing the business classes.
Retrieve and apply
Check yourself
A ReportGenerator class fetches data, applies business rules, formats HTML, and uploads the file. Which principle is the first warning?
Correct. Four unrelated reasons to change live in one class. Split by responsibility first.
Not quite. No subclass is involved, so there is no substitution to break.
Not quite. That principle is about clients forced to depend on methods they do not use. The problem here is one class doing four jobs.
Check yourself
Square extends Rectangle, and setWidth() on a square also changes its height. Code that sets width 4 and height 5 then expects area 20 gets 25. Which principle breaks?
Not quite. No existing class had to be edited. The problem is broken expectations.
Correct. The rectangle's postcondition, that width and height change independently, does not hold for the subclass. A square cannot stand in for a rectangle here.
Not quite. There is no high-level versus low-level layering issue in this example.
Continue to the first creational pattern, Factory Method, where open/closed and dependency inversion meet object creation.
Source: Alexander Shvets, Dive Into Design Patterns (深入设计模式), Chinese edition v2021-1.25. SOLID principles, printed pages 51–68. Explanations, examples, and exercises are adapted for this course.