Every principle here reduces to one number: how many files a requirement change forces you to edit. Each one makes that count small on one axis by charging a price on another.
Vocabulary
- Class: the template. Object/instance: one filled-in copy.
AccountvsAccount(500). - Field/attribute: one piece of an object’s data. Method: a function on the class taking the object as
self. - Invariant: a statement that must hold before and after every method (“balance is never negative”).
- Interface: a named set of method signatures with no code behind them.
- Input to a design: a requirement change (English sentence). Output: files opened + tests re-run. Lower is better.
The four pillars (defined by the edit they prevent)
| Pillar | Makes cheap | The tell it is missing |
|---|---|---|
| Encapsulation | Change internal storage without touching callers | Callers reach into fields; a rename breaks a dozen files |
| Abstraction | Swap how without renegotiating what | Every caller knows which database you use |
| Inheritance | Add a subtype existing callers already handle | A subclass overrides a method to raise NotImplementedError |
| Polymorphism | Add a case without editing the dispatcher | A growing chain of isinstance checks |
- Encapsulation is not “make fields private”. It is “exactly one named place may violate the invariant”. Python has no real
private: leading_is a convention,__triggers name mangling (still not privacy). - Use
type(x) is int, notisinstance(x, int):boolis a subclass ofint, soisinstance(True, int)isTrue. - Read-only field =
@propertygetter with no setter. Never writeget_x()/set_x()in Python. - Abstraction test: would this promise still hold if I rewrote the internals tomorrow? If not, it was implementation with a docstring.
- Polymorphism math:
4operations switching on type ×1new type =4edits; polymorphic = one new class, zero edits. mypy can’t catch a missed switch (every branch is valid code).
SOLID (violation → fix)
| Principle | Tell | Fix | |
|---|---|---|---|
| S | One reason (one actor) to change | Finance, design, DBA in one file | Split by actor into separate classes |
| O | Open to extension, closed to modification | if kind == ... chain you edit per case | Strategy: one class per rule, dispatch via dict |
| L | Subtype usable by code that never heard of it | stretch(Square(3)) fails; Rectangle passes | Immutable value objects sharing a Protocol role |
| I | No client depends on methods it doesn’t use | scan() raising NotImplementedError | One interface per capability |
| D | Policy owns the abstraction, detail implements it | Test needs a real server running | Inject the dependency; test with a fake |
- LSP rules: a subtype may not strengthen a precondition, weaken a postcondition, break a base invariant, or throw a new exception type. Removing a capability the base promised means it isn’t really a subclass.
- OCP cost: dict dispatch turns a bad key into a runtime
KeyErrorinstead of a visible missing branch. - ISP: interfaces belong to the caller.
Protocol(structural, just have the methods) vsABC+@abstractmethod(nominal, must declare and checked at construction). - DIP payoff: the test needs nothing installed. Use
field(default_factory=list), neversent: list = [](shared across all instances).
Composition over inheritance
- Composition = has-a (hold a part, delegate); inheritance = is-a (substitutable).
- Inheritance multiplies, composition adds:
3powertrains ×2bodies =6subclasses (× a third axis =12); composed =3 + 2 + 2 = 7parts. - Rule: inherit to be substitutable, compose to reuse. If your reason is “B needs A’s
save()”, it’s composition. - Composition assumes axes vary independently. When the arithmetic says addition, you’ve assumed independence — the assumption most likely to be wrong.
Coupling & cohesion (both are counts)
- Coupling: how many modules must exist for this to run, and how many break when it changes.
- Efferent
Ce= modules this imports; afferentCa= modules that import this. - Instability =
Ce / (Ce + Ca), from0.0to1.0. Near1.0= a leaf (fine); near0.0= a stable core (must not change). Danger = highCeand highCa. - Law of Demeter: talk only to immediate friends. Each dot past the first is a class you depend on (
a.b.c.dknows 2 structures).
- Efferent
- Cohesion: what fraction of methods touch the same fields. Two disjoint field groups = two classes (no test exercises both halves).
Singleton: don’t
- A Singleton allows one instance via a global access point. It is a global variable with a constructor. Three costs:
- Hides the dependency (signature takes nothing, calls
Config()inside). - Makes tests order-dependent (shared memory; test 2 fails only after test 1 ran). No fix but a
reset(), which admits the design is wrong. - Lazy init is a race (
if _inst is Noneis a check-then-store gap; the GIL doesn’t save you).
- Hides the dependency (signature takes nothing, calls
- Instead: make “one of these” a lifetime decision, not an access one. Construct one in
main, inject it. In Python, a module-level object is the idiomatic process-wide form (import is cached, thread-safe under the import lock). - Defensible only for a stateless, immutable, process-wide resource where injection is impractical (logger, connection pool) — and keep the constructor injectable.
The transferable skill
Every class structure is a bet about what varies. Ask: what did I assume varies (params, strategies, subtypes)? What is fixed (signatures, return shapes, enum lists)? What would the opposite bet have built? The failure always takes the same form: the part you froze is the part that needed to change. You can’t freeze less (an interface that promises nothing isn’t one) — so name the bet and know what the losing case costs.