Software Design Reference Reference · Design Principles

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

PrincipleQuestion it answersMisused when…Lesson
High cohesionDoes this module have one purpose?…you split until every class has one method.0001
Low couplingCan this module change alone?…you hide every dependency behind an interface.0001
SRPHow many reasons does this have to change?…you fragment code to satisfy an accountant.0006
DRYWhere is this knowledge stored once?…you deduplicate text that encodes different knowledge.0002
KISSWhat is the least complex solution that works?…you mistake familiar complexity for simplicity.0002
YAGNIWhat evidence is there that this will be needed?…you refuse every generality, even the free ones.0030
Information hidingWhich decisions here are likely to change?…you hide what callers must reason about.0019
Deep modulesHow much work does this interface hide?…every class gets an interface “for testability”.0034
Law of DemeterIs this talking to a stranger?…you forbid every chain, including the owner's own.0003
Tell, don't askIs this object doing its own work?…you write methods that return and test nothing.0003
Composition > inheritanceIs behaviour shared by structure or delegation?…you ban inheritance entirely.0007
Depend on abstractionsDoes the arrow point at a stable interface?…you add an interface per class.0009
Separate creation from useWho decides which concrete thing runs?…you inject everything, including constants.0023
Open–closedDoes a new case add code or edit code?…you build the extension point before case two.0008
Start with the dataWhat flows through this, and what is true of it?…“the data” quietly means the database schema.0012
Invalid states unrepresentableCan a caller build a broken value at all?…edge validation is called an invariant.0011
Fail fastWhere does this failure belong?…a per-item failure kills the whole batch.0014
Define errors out of existenceCan this stop being an error case?…the error is swallowed rather than designed away.0014
Design it twiceWhat 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.

Taught in: lessons 0001, 0006

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.

Taught in: lessons 0002, 0006

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

Taught in: lessons 0002, 0028

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

Taught in: lessons 0002, 0030

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.

Taught in: lessons 0019, 0034

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()

Taught in: lessons 0001, 0034

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:

DegreeWhat it looks likeWhy it hurts
Contentother._items.append(x)You depend on internals the owner never promised.
Commonboth modules read config._settingsBehaviour depends on who ran first — global coupling.
Controlsend(data, urgent=True)The caller must understand the callee's branches to use it.
Stampdef price(order: Order), using one fieldYou depend on a whole structure to read a part of it.
Datadef 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.

Taught in: lessons 0001, 0019

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.

Taught in: lessons 0003, 0011

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: ...

Taught in: lessons 0007, 0017

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: ...

Taught in: lessons 0009, 0023

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))

Taught in: lessons 0019, 0023

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: ...

Taught in: lessons 0004, 0008, 0016

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

Taught in: lessons 0012, 0015

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

Taught in: lessons 0011, 0012

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.

Taught in: lessons 0014, 0021

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.

Taught in: lessons 0002, 0027

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.