Skip to content

9.3.10 — The Rules That Never Got an Acronym

SOLID gets the attention because it spells a word. The rules on this page come up more often in real code review, and several of them catch bugs that none of the five would.

Each one gets the same treatment: the sentence, the code, and when to ignore it.

1. Separation of Concerns

The rule: a concern is one kind of decision. Keep different kinds in different places.

This is the oldest idea on the page and the parent of Single Responsibility. The difference is scale: Single Responsibility is about who asks for a change, while Separation of Concerns is about what kind of thinking a piece of code requires.

typescript
// ❌ four kinds of thinking in one function
async function handlePlaceOrder(req: Request, res: Response) {
  const body = JSON.parse(req.body);                              // (1) transport
  if (!body.items?.length) return res.status(400).send("no items"); // (2) validation
  const total = body.items.reduce(
    (s: number, i: any) => s + i.price * i.qty, 0) * 1.18;        // (3) business rules
  await pool.query("INSERT INTO orders (total) VALUES ($1)", [total]); // (4) storage
  res.status(201).json({ total });
}

Line (1) is about HTTP. Line (2) is about input safety. Line (3) is a pricing rule, complete with a tax rate hidden in a multiplication. Line (4) is SQL. Four different kinds of thinking in six lines, so a reader has to switch mental gear on every line.

The cost is concrete rather than aesthetic. You cannot check the pricing rule without constructing an HTTP request. You cannot change the tax rate without opening a web handler. And the tax rate itself — 1.18 — is now invisible to anybody searching for tax logic.

The fix is one layer per concern: the handler deals with HTTP, a validator turns unknown input into a typed command, the domain computes the price, a repository writes it. Each can be read, changed and checked without the others.

When to ignore it: a one-off script, or a tiny endpoint that reads one row and returns it. Four layers to fetch a health check is ceremony.

2. Law of Demeter

The rule: talk to your immediate neighbours, not to strangers.

typescript
// ❌ this line knows the shape of four objects
const code = order.customer.address.country.code;                 // (1)

// ✅ ask the object you were given
const code = order.deliveryCountryCode();                         // (2)

Line (1) works today and breaks the moment any of those four shapes changes. If address becomes optional, this line throws. If country becomes a string instead of an object, this line stops compiling. The calling code has taken on knowledge of the internal structure of three objects it was never handed.

Line (2) knows only about order. Every one of those changes becomes a one-line fix inside Order rather than a hunt through the codebase.

The name is unfortunate — it is a guideline, not a law — and the strict version ("one dot per line") is silly. What matters is the distinction:

Walking a structure is the problem. a.b.c.d where each step reveals more internal shape.

Chaining on a fluent builder is not. db.select().where().limit() returns the same kind of thing each time and reveals nothing about internals (9.4.4).

Chaining on immutable values is not. price.plus(tax).times(quantity) is fine, because each step returns a value, not a peek inside an object.

When to ignore it: plain data. If you are holding a parsed JSON response or a database row, reaching into it is what it is for. The rule protects objects with behaviour, not records.

3. Tell, Don't Ask

The rule: tell an object what you want. Do not ask for its data and decide on its behalf.

typescript
// ❌ the caller makes the wallet's decision, outside the wallet
if (wallet.balance.cents >= order.total.cents) {
  wallet.balance.cents -= order.total.cents;
}

// ✅ the owner of the data owns the decision
wallet.spend(order.total);

Every if written about another object's data is a decision that has escaped its home, and escaped decisions get copied — the next feature that needs a funds check copies those three lines, and now the rule exists twice. 9.2.2 works through why this matters and what it costs when it goes wrong.

The review question that catches it instantly: who owns this decision?

When to ignore it: reading data for display. if (order.status === "delivered") showBadge() is asking, and it is fine, because deciding what to draw is genuinely the screen's job, not the order's.

4. Command Query Separation

The rule: a method either changes something or answers a question. Not both.

typescript
// ❌ both: it answers and it mutates
function nextTicketNumber(): number {
  return this.counter++;                                          // (1)
}

// ✅ separated
function currentTicketNumber(): number { return this.counter; }   // a question
function issueTicket(): void { this.counter++; }                  // a command

Line (1) looks harmless until somebody adds a log line: console.log(nextTicketNumber()) during debugging skips a ticket number, permanently, and the tickets have a gap nobody can explain. That is the real cost — a question you can ask twice is safe to log, cache, retry and debug; a question that also does something is none of those.

When to ignore it: some operations genuinely must be atomic. queue.pop() removes an item and returns it, and splitting that into "peek" and "remove" creates a race where two workers get the same item. When you break this rule, break it in a name that admits it: pop, take, claim — all verbs that warn you something is being consumed.

5. Fail Fast

The rule: when something is wrong, stop at once, loudly, close to the cause.

typescript
// ❌ carries on with something broken
function applyDiscount(order: Order, percent: number): Money {
  if (percent < 0 || percent > 100) return order.total;           // (1) silently ignores
  return order.total.minus(order.total.times(percent / 100));
}

// ✅ refuses
function applyDiscount(order: Order, percent: Percentage): Money {  // (2)
  return order.total.minus(order.total.times(percent));
}

Line (1) treats a nonsensical discount as "no discount". A pricing bug upstream that produces -50 now shows up as customers not receiving a promotion they were promised, and the log says nothing. Somebody will spend a day on it.

Line (2) removes the possibility instead of handling it: Percentage validated its own range when it was created (9.2.1 section 4), so a bad value fails at the moment it is produced, with a stack trace pointing at the code that produced it.

That is the strongest form of failing fast: make the bad value impossible to construct, so the failure happens at the source rather than three layers later where the cause is invisible.

When to ignore it: at the outer edge of the system, where input comes from users or other companies. There, bad input is expected, not a bug, and the right response is a clear error message and a logged event, not a crash. Fail fast on programmer mistakes; fail gracefully on user mistakes. Knowing which is which is the skill.

6. Principle of Least Astonishment

The rule: code should do what its name and shape suggest.

typescript
getUser(id);          // ❌ if it creates a user when none exists — say findOrCreateUser
order.total;          // ❌ if reading it calls the tax service over the network
list.sort();          // ❌ if it returns a copy, callers will assume it mutated in place

Each of these works. Each one will burn somebody, because the reader's reasonable assumption was wrong and nothing told them. The cost is not the surprise itself but the loss of trust: after one of these, a careful engineer stops believing any name in the codebase and starts reading every body, which is exactly the expense that good naming was supposed to remove (9.1 section 4).

The check: could a new team member predict what this does from its name, and would they be right?

7. Encapsulate What Varies

The rule: find the part that keeps changing, and put a boundary around it.

This is the sentence underneath most of the design patterns in 9.4, and it is worth stating on its own because it tells you where to apply everything else. Not "add abstractions", but "add them exactly where change has already proven it lands".

The evidence is in the git history. Whatever files have been edited repeatedly for the same class of reason are pointing at your varying part. Everything else stays concrete (9.3.6 section 3).

8. Program to an Interface

The rule: depend on what something can do, not on which class it is.

Fully developed in 9.2.3 and 9.3.9. Worth restating here only for the sharp version: name the role after what the caller needs, and if you cannot imagine a second implementation, you have renamed a class rather than created an abstraction.

9. The Boy Scout Rule

The rule: leave every file you touch slightly better than you found it.

This is the only entry on the page that is about process rather than structure, and it is the one that compounds. Rename one lying variable. Add the missing guard clause. Delete the dead branch. Small, in the file you were already editing and already testing.

Two reasons it works where cleanup projects fail. It has no scheduling cost, so nobody has to approve it. And it naturally targets the files that are touched most often, which are exactly the files where mess is expensive (9.1 section 5 on hotspots).

The limit: stay inside the change you were making. A pull request titled "fix delivery fee" that also renames forty things across nine files is unreviewable, and the reviewer's only honest response is to reject it. Small enough to review alongside the real change, or it goes in its own commit.

10. Using ten rules without becoming insufferable

A short summary of the whole chapter, because a list of rules is dangerous in the wrong hands.

Rules are for finding problems, not for winning arguments. Use them to notice something, then describe the actual consequence. "The fifth payment method will touch these nine files again" persuades. "This violates Open/Closed" starts a fight nobody learns from.

They conflict, and the tiebreaker is always the same. DRY against YAGNI, Single Responsibility against KISS, Open/Closed against YAGNI. When two pull in different directions, ask which choice makes the next change cheaper and safer (9.1). That question has an answer for your codebase this week, which is more than any rule can offer.

None of them is free. Every abstraction is a file somebody opens later. Being able to name what you paid is the difference between design and ritual.

Most of them collapse into one instruction. Isolate what changes behind what does not. Encapsulation applies it to state, abstraction to contracts, Open/Closed to behaviour, Dependency Inversion to vendors, Separation of Concerns to kinds of thinking. Once you can derive them, you can stop reciting them.

Recall

  • Separation of Concerns — different kinds of thinking in different places. A handler doing HTTP, validation, pricing and SQL means you cannot check the pricing rule without building an HTTP request.
  • Law of Demeter — talk to neighbours, not strangers. order.customer.address.country.code knows four shapes and breaks when any changes. Walking a structure is the problem; chaining a builder or an immutable value is fine. Plain data is exempt.
  • Tell, Don't Ask — every if about another object's data is a decision that escaped its home. Review question: who owns this decision? Reading for display is exempt.
  • Command Query Separation — a question you can ask twice is safe to log, cache and retry; this.counter++ inside a getter means a debug log silently skips a ticket number. Break it only in a name that admits it: pop, take, claim.
  • Fail Fast — stop at the cause, not three layers later. Strongest form: make the bad value impossible to construct. Fail fast on programmer mistakes, gracefully on user mistakes.
  • Least Astonishment — a getUser that creates, a getter that calls the network, a sort that returns a copy. The real cost is that readers stop trusting every name.
  • Encapsulate what varies — the git history tells you where. Program to an interface — if you cannot name a second implementation, you renamed a class.
  • Boy Scout Rule — leave each touched file slightly better. It compounds, has no scheduling cost, and targets the files touched most. Keep it inside the change you were making.
  • Rules find problems; consequences win arguments. They conflict, and the tiebreaker is always which choice makes the next change cheaper and safer.

Self-test: Give the difference between Separation of Concerns and Single Responsibility. Which chained calls does Law of Demeter forbid, and which are fine? Show how a debug log can break Command Query Separation. State the split between failing fast and failing gracefully. Why does the Boy Scout Rule succeed where cleanup projects fail?

Quiz Bank

InterviewName design principles outside SOLID that you actually use, and what each one catches.

Tell, Don't Ask catches decisions living in the wrong place. Any if written about another object's data is a rule that escaped its owner, and escaped rules get copied — the next feature that needs the same check copies the lines, and now the rule exists in two places that will drift. The review question is who owns this decision?

Law of Demeter catches hidden structural coupling. order.customer.address.country.code means this line knows the internal shape of four objects, so making address optional breaks a file that has nothing to do with addresses. The useful version distinguishes walking a structure, which is the problem, from chaining on a builder or on immutable values, which is fine. Plain data such as a parsed response is exempt, because reaching into it is what it is for.

Command Query Separation catches an entire class of debugging bug. A method that answers a question and changes something cannot be safely logged, cached or retried — put console.log(nextTicketNumber()) in temporarily and you permanently skip a ticket number. When the operation genuinely must be atomic, such as taking an item off a queue, break the rule in a name that warns you: pop, take, claim.

Fail Fast catches errors far from their cause. Returning "no discount" for an invalid percentage turns an upstream bug into a customer complaint with no log line. The strongest form is making the bad value impossible to construct, so it fails where it was produced. The nuance to state is the split: fail fast on programmer mistakes, fail gracefully on user or third-party input, because there bad input is expected rather than a bug.

The Boy Scout Rule is the process one, and it is the only item that compounds. Leave every file you touch slightly better. It works where cleanup projects fail because it needs no approval and naturally targets the files touched most often, which are exactly where mess is expensive.

The framing to close on: these are for noticing problems, not for winning arguments. In a review, name the consequence rather than the rule — "the fifth payment method will touch these nine files again" moves people in a way that citing an acronym never does.