Reference document · the forces, not the rules
Design Principles
Principles are not rules you obey; they are forces you balance. Each entry gives the principle's job, the check that applies it, and — the part most lists omit — the way it fails when it is followed as a rule. Terms follow the glossary; every entry names the lesson that teaches it.
The nineteen, at a glance
| Principle | Question it answers | Misused when… | Lesson |
|---|---|---|---|
| High cohesion | Does this module have one purpose? | …you split until every class has one method. | 0001 |
| Low coupling | Can this module change alone? | …you hide every dependency behind an interface. | 0001 |
| SRP | How many reasons does this have to change? | …you fragment code to satisfy an accountant. | 0006 |
| DRY | Where is this knowledge stored once? | …you deduplicate text that encodes different knowledge. | 0002 |
| KISS | What is the least complex solution that works? | …you mistake familiar complexity for simplicity. | 0002 |
| YAGNI | What evidence is there that this will be needed? | …you refuse every generality, even the free ones. | 0030 |
| Information hiding | Which decisions here are likely to change? | …you hide what callers must reason about. | 0019 |
| Deep modules | How much work does this interface hide? | …every class gets an interface “for testability”. | 0034 |
| Law of Demeter | Is this talking to a stranger? | …you forbid every chain, including the owner's own. | 0003 |
| Tell, don't ask | Is this object doing its own work? | …you write methods that return and test nothing. | 0003 |
| Composition > inheritance | Is behaviour shared by structure or delegation? | …you ban inheritance entirely. | 0007 |
| Depend on abstractions | Does the arrow point at a stable interface? | …you add an interface per class. | 0009 |
| Separate creation from use | Who decides which concrete thing runs? | …you inject everything, including constants. | 0023 |
| Open–closed | Does a new case add code or edit code? | …you build the extension point before case two. | 0008 |
| Start with the data | What flows through this, and what is true of it? | …“the data” quietly means the database schema. | 0012 |
| Invalid states unrepresentable | Can a caller build a broken value at all? | …edge validation is called an invariant. | 0011 |
| Fail fast | Where does this failure belong? | …a per-item failure kills the whole batch. | 0014 |
| Define errors out of existence | Can this stop being an error case? | …the error is swallowed rather than designed away. | 0014 |
| Design it twice | What is the genuinely different second design? | …the second design decorates the first. | 0027 |
The shape of one module
What a single module owes its readers.
High cohesion
Job: keep everything that changes together, together. A module has high cohesion when its parts serve one purpose and must be understood together.
Check: would a new engineer describe this module in one sentence, with no comma and no “and”? If the honest sentence needs a conjunction, you have two modules.
Signs of trouble: a function that does “a few things” in sequence; a class named Utils; a module whose name needs a conjunction (“orders and payments”).
Misused when: you split until every class has one method. Method count is not cohesion; change source is.
SRP — one reason to change
Job: gather the code that changes for the same reason; separate the code that changes for different reasons. The unit is the reason, not the function.
Test: “we must change X because the tax rules changed” and “we must change X because we switched databases” — if both sentences are true of one module, it has two reasons.
Where to find the reasons: git log on the file. The commit subjects are the change sources, already written down by the people who made them.
Misused when: you split a cohesive class because it has several methods, or fragment code to satisfy an accountant.
DRY — once and only once
Job: store each unit of knowledge in one place: the interest rate, the field mapping, the business rule.
Subtlety: two snippets can be textually identical and encode different knowledge. Merging them is false DRY — coupling between facts that will diverge.
Rule of three: wait for three occurrences before extracting; by the third the shape of the shared thing is visible rather than guessed.
Misused when: you deduplicate text instead of knowledge.
# identical today, owned by different parties — two facts, not one
def fee_for_bank_transfer(amount: Money) -> Money:
return amount * Decimal("0.01") # set by our bank
def fee_for_wire_transfer(amount: Money) -> Money:
return amount * Decimal("0.01") # set by the clearing house
KISS — keep it simple
Job: minimise the complexity you must carry. Complexity is what makes code hard to understand and change — it is the thing you are designing against.
Note: KISS does not mean “shortest code”. A clear twenty-line function beats a clever five-line one. It means least total cognitive load.
Misused when: you mistake familiar complexity for simplicity — a framework you know well still costs every newcomer.
Taught in: lesson 0002
YAGNI — you aren't gonna need it
Job: keep unproven generality out. Every “someday we'll need X” hook is code with no evidence, paid for on every future read.
Balance: YAGNI is a trade-off, not a dogma. When the generality is nearly free and the retrofit is expensive, take it; see generality vs specificity.
Misused when: you refuse every generality, including the free ones.
# the tell: a parameter no caller has ever passed
def export(rows, fmt="csv", *, compress=False, encoding="utf-8"):
...
export(rows) # …and every call site in the repo looks exactly like this
Information hiding
Job: conceal the design decisions most likely to change — storage format, protocol details, algorithm choice — behind a stable interface.
Check: name the decision the module is hiding. If you cannot name one, the module is a pass-through, not an abstraction.
Pair with: Ports & Adapters as the mechanism; the deep module as the goal.
Misused when: you hide what callers must reason about — a timeout, a cost, a failure mode — and they discover it in production.
Deep modules
Job: maximise the ratio of hidden implementation to exposed interface. Depth, not size, is what makes a module worth having.
Check: count the concepts a caller must learn against the work they get. A large interface over a small implementation is a shallow module — pure overhead.
Misused when: every class gets an interface “for testability”, doubling the vocabulary without hiding a single decision.
# deep: one concept in, all the storage decisions hidden
orders.get(order_id)
# shallow: the caller learns sessions, queries, rows and mapping
session.execute(select(OrderRow).where(OrderRow.id == order_id)).scalar_one()
The relationships between modules
Who may know about whom, and how behaviour is shared.
Low coupling
Job: make modules replaceable. A module with low coupling can change, be tested, or be swapped without its neighbours noticing.
Check: if I rewrite module A's internals, how many files must change? One? Good. Fifteen? That is change amplification.
The degrees, worst to best:
| Degree | What it looks like | Why it hurts |
|---|---|---|
| Content | other._items.append(x) | You depend on internals the owner never promised. |
| Common | both modules read config._settings | Behaviour depends on who ran first — global coupling. |
| Control | send(data, urgent=True) | The caller must understand the callee's branches to use it. |
| Stamp | def price(order: Order), using one field | You depend on a whole structure to read a part of it. |
| Data | def price(total: Money) | The floor: you depend on exactly what you use. |
Misused when: you hide every dependency behind an interface — indirection is not decoupling, and each layer is read by everyone forever.
Law of Demeter
Job: limit each method's acquaintances to its immediate neighbours: itself, its own fields, its arguments, objects it creates, and the direct members of its own collections.
Warning sign: a train wreck — the caller knows the internal geography of three objects it does not own.
Fix: move the behaviour to the object that owns the data. This is Tell, don't ask.
Misused when: you forbid every chain. The law limits callers, not owners: a chain inside the object that owns the data is fine.
# wreck: the caller depends on Customer's and Address's shape
city = order.customer.address.city
# tell: the owner answers, and the chain lives where the data does
if order.ships_free_to(FREE_SHIPPING_CITIES):
...
Taught in: lesson 0003
Tell, don't ask
Job: ask objects to do work rather than to hand over their data so you can do it for them. Behaviour belongs with the state it needs.
Check: does the caller pull fields out of an object and then decide something about them? That decision belongs inside the object.
Misused when: you create command methods that return nothing and can be asserted on by nothing — the pendulum swung past testability.
Composition over inheritance
Job: share behaviour by delegating to held objects instead of inheriting from ancestors. Inheritance fuses the subclass to the superclass's every change; composition leaves a visible seam.
Where inheritance earns its keep: polymorphism against a stable interface, where callers depend on the base type and implementations vary.
Smell: subclassing for reuse (“I need the logging, I'll extend Logger”) — a composition scenario in disguise.
Misused when: you ban inheritance entirely, including the genuine is-a hierarchies and the ABCs with real shared behaviour.
# fuse: FileBackedLogger cannot outlive Logger's constructor
class FileBackedLogger(Logger): ...
# seam: it holds a sink, owns its own lifecycle, and takes a fake in tests
class FileBackedLogger:
def __init__(self, sink: Logger, path: str) -> None: ...
Depend on abstractions
Job: point dependency arrows at stable interfaces owned by the domain, not at volatile concrete implementations.
Test: who owns the interface? If the database driver or the payment SDK defines what your domain looks like, the arrow points the wrong way.
Naming test: a port named after a vendor (StripeGateway) has already surrendered the vocabulary. Name it for the need: PaymentGateway.
Misused when: you add an interface per class. Ports live at the application boundary, not between every pair of objects.
# the domain declares what it needs; infrastructure conforms
class PaymentGateway(Protocol):
def charge(self, amount: Money, token: str) -> str: ...
class StripeAdapter: # lives outside the domain
def charge(self, amount: Money, token: str) -> str: ...
Separate creation from use
Job: the code that decides which implementation runs should not be the code that runs it. Factories, dependency injection and the composition root exist for this.
Test: can you run your domain logic against a fake adapter without changing one line of the domain?
Where creation goes: one composition root — a single visible function where every concrete thing is constructed.
Misused when: you inject everything, including constants and value objects, and the wiring becomes the program.
def build_app() -> App: # the only place technology is named
clock: Clock = SystemClock()
orders: OrderRepository = SqlAlchemyOrderRepository(make_session())
return App(checkout=CheckoutService(orders, StripeAdapter(stripe), clock))
Open–closed
Job: let a module gain new behaviour without being edited. New cases arrive as new code, not as new branches inside old code.
Test: when the fifth payment method arrives, do you add a file or edit a function that four other methods depend on?
Mechanism: the extension point — a protocol, a callable, a registry. It is Strategy read as a principle.
Misused when: you build the extension point before the second case exists — that is speculative generality, and YAGNI wins.
# closed for modification: the caller never grows a branch
def price(total: Money, discount: Discount) -> Money:
return total - discount(total)
# open for extension: a new policy is a new function
def loyalty_discount(years: int) -> Discount: ...
The designer's working method
Not properties of code — habits that produce them.
Start with the data
Job: name the data and its shape before naming classes or functions. What flows through the system, what must stay true of it, and who owns it — those answers decide the structure.
Why it works: most design arguments are really disagreements about the data model, conducted in the vocabulary of classes. Settle the data and the class-or-function question usually answers itself.
Practical form: write the value objects and the transitions first, with no I/O. If they read like the domain, the rest is plumbing.
Misused when: “the data” is taken to mean the database schema, and storage decisions leak inward before the domain exists.
# decide this first: what is true of a price, always?
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str # …and the class-vs-function question answers itself
Make invalid states unrepresentable
Job: design types and constructors so that a broken value cannot be built at all. The bugs you cannot write are cheaper than the bugs you catch.
Test: can a caller reach a field directly and put the object in a state no method would allow? If yes, you have a convention, not an invariant.
Scope limit: an invariant that spans two objects cannot be enforced by either — that is what an aggregate or a transaction is for.
Misused when: validation is added at the edges and called an invariant, leaving every other path unguarded.
class Account:
def __init__(self, balance: Money) -> None:
self._balance = balance # private: no caller can assign it
def withdraw(self, amount: Money) -> None:
if amount > self._balance:
raise InsufficientFunds() # the object refuses, for every caller
Fail fast, at the right boundary
Job: fail as close to the cause as possible, loudly, so the error arrives with its address attached instead of detonating three layers away.
The three boundaries: input (refuse before work starts), invariant (the domain refuses the transition), integration (the outside world's failure surfaces as a failure).
Companion rule: never convert an error to None at a boundary where the error still means something. Convert only where a default genuinely makes sense, and name that decision.
Misused when: “fail fast” becomes “crash the batch”: a per-item failure should be recorded per item, not used to abandon forty-nine good ones.
Define errors out of existence
Job: change the interface so the error case stops being a case. The best error handling is the error that cannot occur.
Examples: a delete that succeeds on an absent key; a range that is empty rather than invalid; a constructor that refuses so no downstream check is needed.
Tension: it is the opposite move to fail-fast, and both are right — fail fast on a violated expectation, define out of existence a case that was never really an error.
Misused when: the error is defined away by swallowing it. Returning a default is only legitimate when the default is genuinely correct, not merely convenient.
Taught in: lesson 0014
Design it twice
Job: produce a second, genuinely different design before committing to the first. The comparison is what surfaces the criteria you were deciding by without knowing it.
Cost: usually under an hour, on a decision you will live inside for a year.
Where it pays most: irreversible choices — data models, public interfaces, boundaries between teams.
Misused when: the second design is a decoration of the first. If both options share the same shape, you have designed once and written it twice.
Sources: ArjanCodes, The 7 Principles of Modern Software Design (The Software Designer Mindset); Ousterhout, A Philosophy of Software Design; Martin, Clean Architecture; McConnell, Code Complete ch. 5; Hunt & Thomas, The Pragmatic Programmer; Evans, Domain-Driven Design.