Software Design Reference Core Designer · Seeing Structural Problems

Lesson 0002 · Core Designer · Module 1

SRP, DRY, KISS, YAGNI in Context

These four are quoted as rules and violated as rules. The skill is reading them as forces: each names a failure mode, and each can be wrong.

Reading ≈ 10 minPractice ≈ 20 min

Mission tie-in: AI will happily generate code that "follows the rules" while violating the point. Understanding the forces is what lets you keep control of AI output instead of accepting its letter.

Knowledge: forces, not rules

SRP — one reason to change

The unit is the reason to change, not the method. "We must edit this class because tax rules changed" and "…because we switched databases" — two reasons in one class is the failure. A class with twenty methods but one change source is fine; a class with two methods and two change sources is not.

DRY — once and only once, per unit of knowledge

DRY targets knowledge, not text. These two functions are identical today — and encode different knowledge:

def fee_for_bank_transfer(amount: Money) -> Money:
    return amount * Decimal("0.01")   # bank fee, set by our bank

def fee_for_wire_transfer(amount: Money) -> Money:
    return amount * Decimal("0.01")   # wire fee, set by the clearing house

Both compute 1% today. Merge them into one fee(amount) and the next time one fee changes, you will silently charge the other's customers the wrong amount. That is false DRY — deduplication that creates coupling between things that will diverge. The two fees changing together twice is what earns the merge (the rule of three).

KISS and YAGNI — the cost of complexity

KISS: prefer the least complex solution that meets the requirement. YAGNI: do not build capability with no evidence of need. Both are about unpaid tax: every generality, hook, and abstraction layer is paid for on every future read of the code, whether it's used or not.

† The expensive combination: YAGNI in the present ("too complex to build now") + KISS misread as "shortest code". Shortest is not simplest — a clear twenty-line function beats a clever five-liner.

The context is the whole story: a hook is YAGNI in a two-month project and common sense in a platform other teams will build on. The same line of code can be a violation and a virtue in two codebases.

The principle behind the principle

All four are heuristics about the same hidden quantity: the cost of being wrong, and how expensive the mistake is to undo. Once you see that, they stop competing and start dividing the space.

DRY's asymmetry. Un-merging two things that turned out to be different is cheap — copy the function, give each half its own name, done in an afternoon. Un-coupling two things that were merged years ago is expensive: you must find every caller and decide, per call site, which of the two meanings it wanted, usually without the person who wrote it. Worse, the merge fails silently: nothing crashes when one policy diverges, you just charge the wrong number until someone notices in a reconciliation. Cheap to undo in one direction, expensive and silent in the other — which is why the rule of three says wait.

YAGNI's asymmetry. An unused abstraction is paid on every read by every reader for the life of the code; the same abstraction added later is paid once, by one person, with better information about what it actually needs to do. That is a straightforward win for waiting — provided the later change is local.

Where they stop applying. That proviso is the boundary condition, and it is the one people miss. When the later change is not local — a published API with external consumers, a wire format shared by four teams, a schema with ten million rows already written — the "add it later" cost is a migration, a dual-write window, and a coordination meeting, not an edit. YAGNI weakens exactly in proportion to how irreversible the decision is. Similarly, SRP's unit — the reason to change — is only knowable if you know who edits the code and why; in a domain you have worked in for two weeks you do not know that yet, so a confident split is a guess wearing a principle's clothes.

The practical rule that falls out: be greedy about simplicity where change is cheap, and thoughtful about extension points where change is expensive. Code is cheap. Data, contracts, and other people's calendars are not.

How a senior designer decides the fee question

Return to the two 1% fees. Here is the reasoning, not the verdict.

First they look for the owner, not the code. Who decides each number? One is set in the bank's commercial agreement; the other is published by the clearing house. Two documents, two counterparties, two renewal dates. That answer arrives before anyone opens the function bodies, and it is already most of the decision.

Then the question: when one of these changes, does the other change in the same commit? Not "could they" — has it happened? Check the history of the constants. If the two numbers have moved independently even once, they are two facts and the merge is a bug waiting for a date.

Then they reject the compromise. The tempting middle path is to keep one function and pass a discriminator:

def fee(amount: Money, kind: str) -> Money:
    rate = Decimal("0.01") if kind == "bank" else Decimal("0.01")
    return amount * rate

This is worse than either honest option. It is control coupling: the caller now passes a flag whose only job is to select which of two policies runs, so every caller must know the taxonomy, and the function's signature promises one behaviour while delivering two. It also creates the illusion of having deduplicated — the rate literal is written twice anyway. A senior designer's tell: if the parameter exists only to choose between branches, you have not shared knowledge, you have shared a switchboard.

The consequence over eighteen months. On a four-team payments platform, the wire fee went tiered — 1% up to €10,000, then 0.6%. In the merged version, that change edits the function both teams depend on; the bank team's regression suite starts failing for reasons that have nothing to do with their product, and the review needs someone from each team, so a two-line change waits three days for a quorum. In the kept-apart version it is one file, one reviewer, one afternoon. The duplication that looked like sloppiness was the thing that let two teams move at different speeds.

Field notes · what it looks like in real code
You see thisWhat it costsThe move
def fee(amount, kind)A kind flag is two facts pressed into one function.Keep two functions until a third change moves both.
OrderData + OrderLogicSRP applied by shape, not by change source — one force, two files.Merge them back; split only where the forces differ.
**kwargs three layers deepKISS misread as “less typing”: the contract disappeared.Name the parameters at every layer.
strategy= with one implementationYAGNI: a hook paid for on every read, used by nobody.Delete the hook; add it when case two arrives.

When this is the wrong move

What people get wrong after reading this
DRY means no duplicated text.
DRY is about duplicated knowledge — a fact that would have to be changed in two places if it changed at all. Two functions with identical bodies and different owners are not a DRY violation; one fact spread across a config file, a constant, and a comment is, even though the three look nothing alike.
YAGNI means never design ahead.
YAGNI is about building capability with no evidence of need. Thinking ahead is free and mandatory; the discipline is not shipping the machinery until the evidence arrives, except where the later fix is a migration rather than an edit.
KISS means fewer lines.
Simplicity is measured in the reader's head, not the file's byte count. Density and simplicity are frequently opposites: a comprehension nested three deep is short and hard, an explicit loop is longer and obvious.
These principles conflict, so you have to pick a favourite.
They govern different axes. DRY is about who owns a fact; SRP about what forces edit a module; KISS about the reader's load; YAGNI about the evidence for a capability. A concrete decision usually only triggers one of them, and when two seem to fight, you have mis-stated at least one.

Skill: judge the application, not the principle

Two systems charge the same 5% fee today. Extracting one shared function is:

A class has ten methods but exactly one reason to change. SRP says:

A team adds a version field to an event format that is written to durable storage, before any second version exists. This is:

Practice on your own code

Find one place where a principle was applied as a rule — a shared helper that two things were forced into, a class split "to follow SRP", a generality built "just in case". Name the force it ignored, and then name the reversibility: if this call was wrong, what would undoing it cost in hours and in coordination?

In the lab: the returns-resolution service contains at least one shared helper that two policies were pressed into. The phase-1 checkpoint asks you to decide whether it is true DRY or false DRY using the ownership test — who sets each number, and have they ever moved independently — and to write the one-line justification for whichever way you call it.

Reveal: a worked judgment

An AI assistant refactors a file and reports: "Extracted get_price() shared by sales and returns, following DRY." Look closer: sales prices include VAT; return prices are ex-VAT. The shared helper is false DRY — the moment VAT rules change, returns pricing changes too. The right move is to keep two functions and one documented fact: these rates are set by different policies.

Teach it back
  1. Explain to a junior why two identical functions can be correct design, in two sentences, without using the word "DRY".
  2. Find one abstraction in your own code this week that was added "just in case", and name its cost: how many readers have paid for it, and how many callers have used it.
  3. Predict what breaks in a year if a fact your system owns stays merged with a fact someone else owns — and say who will be the first to notice.

Your win

You can now apply the four principles as a judge, not an executor: for any "violation," name the force it actually threatens — and spot the rule-following that quietly does damage.

Read and watch deeper

Stuck on a judgment call? Ask your agent-teacher: paste the code and the claimed principle, and it will help you name the force actually at stake.