20 · Programming Principles
The durable heuristics that separate code that survives five years of change from code that rots in five months: DRY, KISS, YAGNI, SOLID, separation of concerns, coupling/cohesion, composition over inheritance, and the rest. Language- and framework-agnostic — they apply to a React component, a CSS file, or a microservice.
Positioning
Frameworks come and go; these principles are why some engineers write code others want to maintain. They are heuristics, not laws — each exists to manage one force (change, complexity, duplication, coupling), and each can be over-applied into its own anti-pattern. Seniority is knowing which principle a situation calls for and when to break it. This file assumes only basic programming knowledge (variables, functions, loops) and builds up; everything later in the library leans on it.
Foundations: the two forces underneath every principle
Almost every principle below serves one of two goals:
- Managing complexity — human working memory is tiny. Code must be understandable in small pieces. (KISS, SoC, SRP, cohesion, abstraction.)
- Managing change — requirements always change; the cost of change should stay low and local. (DRY, OCP, DIP, low coupling, composition.)
Coupling and cohesion are the master concepts:
- Coupling = how much one module depends on another. Low coupling is the goal — a change in A shouldn’t force changes in B, C, D.
- Cohesion = how related the things inside one module are. High cohesion is the goal — a module does one well-defined job. The aim everywhere: high cohesion, low coupling. Most other principles are tactics for achieving it.
// ❌ LOW cohesion + HIGH coupling: a grab-bag "utils" module that does unrelated jobs,
// and a caller that reaches into another module's internals.
// WHY IT'S BAD (low cohesion): `utils` mixes date formatting, a network call, and tax math —
// things that change for unrelated reasons. You can't describe it without "and... and...",
// nobody knows where to add the next helper, and importing one function drags the whole grab-bag.
// WHY IT'S BAD (high coupling): `checkout` reads `cart.items[0].price.raw` — it depends on the
// cart's INTERNAL shape. Rename `raw` or wrap `price` and checkout breaks, despite "owning" none
// of that structure. The two modules are welded together; neither can change in isolation.
// utils.ts
export function formatDate(d) {/*...*/}
export async function fetchUser(id) {/*...*/}
export function taxFor(cents, region) {/*...*/}
// checkout.ts
const due = cart.items[0].price.raw + taxFor(cart.items[0].price.raw, region); // reaches in
// ✅ HIGH cohesion + LOW coupling: each module owns one job; callers talk to public surfaces.
// WHY IT'S BETTER (high cohesion): `money.ts` is *only* money math — one reason to change, easy
// to find, safe to import. `tax.ts` is *only* tax. Each has a single, nameable responsibility.
// WHY IT'S BETTER (low coupling): checkout asks the cart for a value (`cart.subtotal()`) instead
// of digging through its fields, so the cart can restructure internally with zero blast radius.
// money.ts → formatBRL, addMoney, ... (cohesive: money only)
// tax.ts → taxFor(subtotal, region) (cohesive: tax only)
// checkout.ts
const due = addMoney(cart.subtotal(), tax.taxFor(cart.subtotal(), region)); // talks to surfaces
Notice every principle below is really a tactic for moving code from the first block toward the second.
Not all coupling (or cohesion) is equal — there’s a spectrum. The names matter less than the gradient, but knowing it sharpens the judgment:
- Coupling, worst → best: content (one module reaches into another’s internals) → common (shared global/mutable state) → control (passing a flag that tells another module what to do) → stamp (passing a whole record when you need one field) → data (passing exactly the values needed) → message (interacting only through a stable interface/events). Push toward the bottom.
- Cohesion, worst → best: coincidental (unrelated things dumped together — the
utilsgrab-bag) → logical (same category, switched by a flag) → temporal (grouped only because they run at the same time, e.g.init()) → communicational/procedural (steps sharing data) → sequential (one step’s output feeds the next) → functional (everything contributes to one well-defined task). Push toward the top.
Get coupling toward data/message and cohesion toward functional and most other principles fall out for free. (Constantine & Yourdon, structured design.)
Deep dive: the core principles
DRY — Don’t Repeat Yourself
“Every piece of knowledge must have a single, unambiguous, authoritative representation.” (Hunt & Thomas, The Pragmatic Programmer.) DRY is about knowledge, not text. If a business rule lives in three places, changing it means finding all three — and missing one is a bug. Extract it once (a function, constant, component, token).
Comparison 1 — WET vs DRY (when extracting is right):
// ❌ WET ("Write Everything Twice"): the SAME KNOWLEDGE — how the member discount is computed —
// is copied into three places.
// WHY IT'S WET: the discount rate is one business fact with three homes. Changing 10% → 15%
// means finding and editing every copy; miss one and the cart, invoice, and preview silently
// disagree. The duplication is of *knowledge*, so it's a real bug magnet.
function cartTotal(items) { return sum(items) * 0.9; }
function invoiceTotal(items) { return sum(items) * 0.9; }
function previewTotal(items) { return sum(items) * 0.9; }
// ✅ DRY: the rule has ONE authoritative representation; everything else derives from it.
// WHY IT'S DRY: the discount exists exactly once (a named constant + a function). A rule change
// is a one-line edit that propagates everywhere automatically — drift is now impossible.
const MEMBER_DISCOUNT = 0.1;
const applyDiscount = (subtotal) => subtotal * (1 - MEMBER_DISCOUNT);
// cartTotal/invoiceTotal/previewTotal all call applyDiscount(sum(items))
Comparison 2 — DRY vs OVER-DRY (when extracting is wrong): this is the senior nuance most people miss.
// Two functions that look identical TODAY but encode DIFFERENT knowledge:
function userDisplayName(u) { return `${u.first} ${u.last}`; } // how we name PEOPLE
function productDisplayName(p) { return `${p.brand} ${p.model}`; } // how we label PRODUCTS
// ❌ OVER-DRY: "deduplicate" them into one helper purely because the bodies match.
// WHY IT'S AN ANTIPATTERN: the match is COINCIDENTAL, not shared knowledge. The day product
// naming needs a size (`${p.brand} ${p.model} ${p.size}`) but people-naming doesn't, you bolt a
// flag onto the shared helper — and it sprouts conditionals serving two unrelated masters. You've
// COUPLED two things that change for different reasons; now each change risks breaking the other.
function displayName(a, b, sep = ' ', extra = '') { return `${a}${sep}${b}${extra}`; } // smell
// ✅ Leave them separate. Duplication of *shape* is cheaper than the wrong abstraction.
// WHY IT'S RIGHT: each name-rule evolves independently with zero blast radius on the other.
// Wait until a THIRD real case reveals a genuinely shared rule before extracting.
“Duplication is far cheaper than the wrong abstraction.” (Sandi Metz.) The test for DRY isn’t “do these look the same?” but “do these represent the same decision, such that they must change together?” If yes → extract. If they merely coincide → leave them. Rule of thumb: tolerate duplication until the third occurrence proves the pattern.
KISS — Keep It Simple, Stupid
Prefer the simplest solution that works. Clever, dense, “look what I can do” code is a liability — it’s read far more often than written. Favor obvious over ingenious; a junior should be able to follow it. Complexity you add must earn its keep.
// ❌ Violates KISS: a nested ternary packing three decisions into one expression.
// WHY IT'S BAD: the reader must mentally unfold the nesting to recover each case, and adding a
// fourth case means re-parsing the whole dense line. "Clever" here buys nothing but read-cost.
const label = a ? (b ? 'AB' : 'A') : (b ? 'B' : 'none');
// ✅ KISS: plain, named branches.
// WHY IT'S BETTER: each case is independently readable and greppable; a new case is a new line,
// not a re-parse. It's longer in characters but far shorter in *time-to-understand* — the metric
// that actually matters, since code is read ~10× more than it's written.
function label(a, b) {
if (a && b) return 'AB';
if (a) return 'A';
if (b) return 'B';
return 'none';
}
YAGNI — You Aren’t Gonna Need It
Don’t build for hypothetical future requirements. Speculative generality (config options nobody uses, abstraction layers for a second database you’ll never have) adds cost and complexity now for value that usually never arrives. Build for today’s known requirements; refactor when the real need appears.
// ❌ Violates YAGNI: a "flexible" store that supports drivers you don't have and may never need.
// WHY IT'S BAD: every option is code to write, test, document, and maintain TODAY for a payoff
// that usually never comes. Worse, you're guessing the future interface — and speculative APIs
// are almost always wrong, so you pay twice (build the guess, then rip it out).
function createStore(opts: {
driver?: 'memory' | 'redis' | 'dynamo' | 'postgres';
shards?: number; replicas?: number; ttl?: number;
} = {}) { /* a branch for each driver, most never exercised */ }
// ✅ YAGNI: build exactly today's need.
// WHY IT'S RIGHT: it's smaller, simpler, fully tested because every line is used, and trivially
// refactorable when a REAL second driver lands — at which point you'll know the actual interface
// instead of inventing one. (YAGNI vs "design for change" resolves here: stay *simple and
// changeable*, not *pre-generalized*.)
function createStore() { return new Map<string, unknown>(); }
Separation of Concerns (SoC)
Split a system along the seams of distinct concerns so each can be reasoned about and changed independently — markup vs behavior vs style; domain logic vs I/O vs presentation (10); data fetching vs rendering. A concern tangled across many places is a change-amplifier. (A concern is a kind of work — a different thing from a responsibility, which is a source of change / who-asks. See “SRP in depth” in the SOLID section, and the SoC-vs-SRP-vs-encapsulation comparison just below.)
// ❌ Tangled concerns: validation rules, formatting, AND markup live in the component body.
// WHY IT VIOLATES SoC: three concerns that change for DIFFERENT reasons and at DIFFERENT rates
// (the email rule, the error copy, the layout) are fused. Touching any one means editing this
// component and risking the others; the validation can't be reused or unit-tested on its own.
function SignupField({ email, onChange }) {
const valid = /^[^@]+@[^@]+\.[^@]+$/.test(email);
return (
<div>
<input value={email} onChange={e => onChange(e.target.value)} />
{!valid && <span style={{ color: 'red' }}>Please enter a valid email address</span>}
</div>
);
}
// ✅ Separated: each concern has its own home.
// WHY IT'S BETTER: the rule is a pure, testable function (reusable on the server too, 12); the
// component only renders. A change to the rule, the message, or the layout each touches exactly
// one place. Concerns now change independently.
export const isEmail = (s: string) => /^[^@]+@[^@]+\.[^@]+$/.test(s); // domain rule (pure, testable)
function SignupField({ email, onChange }: Props) { // presentation only
return (
<Field invalid={!isEmail(email)} error="Please enter a valid email address">
<input value={email} onChange={e => onChange(e.target.value)} />
</Field>
);
}
Encapsulation & information hiding
Bundle a unit’s data with the behavior that operates on it, and hide its internals behind a controlled interface (information hiding — David Parnas, 1972). Callers use a small public API; they can’t reach in to corrupt internal state or depend on representation details. Two facets: bundling (data + methods together) and the deeper information hiding (private internals, public surface).
// ❌ No encapsulation: internals are public, so any code anywhere can break the invariant.
// WHY IT'S BAD: the rule "every item has positive qty and total matches items" can be violated from
// anywhere, and the resulting bug surfaces FAR from the cart code. Callers also depend on `items`
// being an array, so you can never change that representation without breaking them.
class Cart {
items = []; // exposed guts
}
cart.items.push({ qty: -3 }); // bypasses every rule; negative qty; total now stale
cart.items.length = 0; // silently "clears" the cart; listeners never notified
// ✅ Encapsulation: state is private; the only way in is through methods that keep it valid.
// WHY IT'S RIGHT: there's exactly ONE door in, so the invariant holds by construction. `#items`
// could become a Map tomorrow with zero impact on callers — they depend on the interface, not the guts.
class Cart {
#items = []; // hidden representation
add(item) {
if (item.qty <= 0) throw new RangeError('qty must be positive'); // invariant enforced in ONE place
this.#items.push(item);
this.#emitChange(); // can't be bypassed
}
get total() { return this.#items.reduce((s, i) => s + i.price * i.qty, 0); } // derived, never stale
}
In FP it’s the same idea by different means: module boundaries (export only the public functions; keep helpers and data shapes module-private) and closures (private state captured in a closure; the returned functions are the only way in). Immutability does part of encapsulation’s job for free — you can’t corrupt state from afar if nothing is mutable — and a smart constructor returning an immutable value encapsulates a validity invariant (the branded Ratio from the Fail-Fast example is exactly this: the only way to obtain a Ratio is through toRatio(), which guarantees the 0..1 rule).
How encapsulation compares to SoC and SRP — they’re constantly lumped together but do different jobs:
- SoC decides what kind of work goes where (keep validation, rendering, and persistence apart).
- SRP decides how big a unit is and where its edge falls (one unit per actor / reason to change).
- Encapsulation decides what may cross that edge — it hides the internals so the rest of the system can depend only on the public interface.
In one line: SoC and SRP draw the boundaries; encapsulation builds the wall and locks the door. This matters because a “separated” module whose internals are public is only cosmetically separated — everything can still reach into everything, so you have high coupling despite the tidy file layout. Encapsulation is what turns a separation into a decoupling. It also adds something neither SoC nor SRP provides: protection of invariants — outside code can’t put the data into an invalid state. And it’s what makes Dependency Inversion and the Law of Demeter possible (callers talk to an interface, not to internals). The OOP-pillar treatment is in 21.
SRP vs encapsulation, specifically — they act on different axes, so they’re independent:
- SRP is about how a unit is divided — “should this be one unit or two?”, cut by reason-to-change/actor. A decomposition rule about cohesion (deciding where the boundary goes).
- Encapsulation is about what crosses a unit’s boundary once drawn — “what can the outside see and touch?”, by hiding internals behind an interface. An information-hiding + invariant-protection rule about coupling and integrity (making the boundary real).
They’re orthogonal — every combination exists: a class with one responsibility but all fields public (good division, no protection — SRP without encapsulation); a god object that hides everything behind methods yet does five unrelated jobs (well-sealed, badly divided — encapsulation without SRP); or one responsibility behind a minimal interface (both — the goal). SRP decides where the wall goes; encapsulation decides what the wall lets through, and protects what’s behind it.
Composition over Inheritance
Prefer building behavior by combining small pieces (functions, components, objects that hold collaborators) over deep inheritance hierarchies. Inheritance couples a child to its parent’s internals and is rigid. Composition is flexible and the dominant pattern in modern frontend (React composes components and hooks; 05). (More in 21/22.)
// ❌ Inheritance for reuse.
// WHY IT'S THE ANTIPATTERN HERE: behavior is locked into a rigid tree. A Penguin IS-A Bird, but
// `fly()` doesn't apply — so you override it to throw, which BREAKS the base contract (an LSP
// violation; callers that loop over Birds calling fly() now crash). New combinations (a swimming,
// non-flying bird) force yet more subclasses. This is the "banana → gorilla → jungle" pull:
// you wanted one method and inherited the whole hierarchy's assumptions.
class Bird { fly() {/*...*/} eat() {/*...*/} }
class Penguin extends Bird { fly() { throw new Error("penguins can't fly"); } } // smell
// ✅ Composition: capabilities are small pieces you mix per object.
// WHY IT'S BETTER: there's no fragile base class to break. A penguin simply doesn't get the fly
// capability — so it can't lie about it. Any new combination is just a different set of mixed-in
// behaviors. HAS-A ("a penguin has the ability to swim") beats IS-A.
const canEat = () => ({ eat: () => {/*...*/} });
const canFly = () => ({ fly: () => {/*...*/} });
const canSwim = () => ({ swim: () => {/*...*/} });
const makeSparrow = () => ({ ...canEat(), ...canFly() });
const makePenguin = () => ({ ...canEat(), ...canSwim() }); // no fly() to misuse
Law of Demeter (Principle of Least Knowledge)
“Only talk to your immediate friends.” A method should call methods on: itself, its parameters, objects it creates, and its direct fields — not reach through chains. Long reach-through chains (train wrecks) couple you to the internal structure of distant objects.
// ❌ Train wreck — reaching through three layers of someone else's internal structure.
// WHY IT VIOLATES LoD: this line "knows" that a user has a profile, which has an address, which
// has a zip. If ANY link in that chain changes shape (profile→contactInfo, address→location),
// this breaks — even though it only ever wanted a zip code. You're coupled to the whole object
// graph, not just to `user`. It's also un-mockable without building the entire nested structure.
const zip = user.getProfile().getAddress().getZip();
// ✅ Tell, don't ask — talk to one friend and let it handle the traversal.
// WHY IT'S BETTER: the caller depends only on `user`'s public surface. Profile/address can be
// restructured freely without touching this code, and `user` is trivial to stub in a test.
const zip = user.zipCode(); // user encapsulates the traversal
// (or push the behavior itself onto the object: shipLabelFor(user))
Principle of Least Astonishment (POLA)
Code should behave the way a reasonable reader expects. Surprises are bugs waiting to happen and onboarding tax.
// ❌ Astonishing — a "getter" with a hidden side effect.
// WHY IT VIOLATES POLA: the name `getUser` promises a pure read, but it also WRITES (records a
// login). A caller who just wants to display a name unknowingly mutates state — producing
// "why did rendering a profile create a login event?" bugs that are brutal to trace, because the
// cause hides behind an innocent-looking name. Names are a contract; this one lies.
function getUser(id: string) {
const u = db.find(id);
db.recordLogin(id); // surprise!
return u;
}
// ✅ Unsurprising — each name does exactly what it says, side effects are explicit and opt-in.
// WHY IT'S RIGHT: a reader can trust the names; nothing happens that the call site didn't ask for.
function getUser(id: string) { return db.find(id); }
function recordLogin(id: string){ db.recordLogin(id); } // caller chooses when to invoke
Command–Query Separation (CQS)
A method should be either a command (does something / changes state, returns nothing) or a query (answers something / returns data, no side effects) — never both. (Bertrand Meyer.) Asking a question shouldn’t change the answer.
// ❌ Violates CQS: a "query" that also mutates (it's the POLA example too — the principles overlap).
// WHY IT'S BAD: nextId() looks like a read but advances a counter, so calling it twice gives
// different answers, and merely logging it for debugging changes program state. You can't safely
// reorder, repeat, or cache a query that secretly mutates.
function nextId() { return ++this.counter; } // query that mutates
// ✅ CQS: separate the question from the action.
// WHY IT'S RIGHT: currentId is a pure, repeatable query; advance() is the explicit command. Reads
// are now free to call, reorder, and cache; writes are visible at the call site.
get currentId() { return this.counter; } // query: no side effects
advance() { this.counter++; } // command: returns nothing
At architectural scale this same instinct becomes CQRS — separate read and write models (13). CQS is CQRS at the method level.
Single Source of Truth (SSOT)
Each fact lives in exactly one place; everything else derives from it. Duplicated state that can drift (the same value cached in three components) is a bug factory — this is DRY applied to runtime state and underlies state-management design (06) and design tokens (11).
// ❌ Violates SSOT: the same fact (which items are in the cart) is stored TWICE — once as `items`
// and once as a derived `count` that's manually kept in sync.
// WHY IT'S BAD: `count` is a COPY of a fact that already lives in `items`. Every code path that
// mutates the cart must remember to update `count` too; the first one that forgets makes the badge
// disagree with the list. Two sources of one truth WILL drift — it's only a question of when.
const [items, setItems] = useState<Item[]>([]);
const [count, setCount] = useState(0);
function add(item) { setItems([...items, item]); setCount(count + 1); } // must touch both, forever
// ✅ SSOT: store the fact ONCE; derive everything else on the fly.
// WHY IT'S RIGHT: `items` is the single authority; `count` is computed from it, so they can never
// disagree. There's no "keep in sync" step to forget. Derive, don't duplicate — the same reason
// selectors/derived-state exist in stores (06) and tokens have one definition (11).
const [items, setItems] = useState<Item[]>([]);
const count = items.length; // derived — always correct
function add(item) { setItems([...items, item]); } // one fact, one update
Fail Fast & Make Illegal States Unrepresentable
Surface errors at the earliest, closest point (validate at the boundary, 12; throw on invalid input) rather than letting bad data flow downstream where the failure is far from the cause. Better still, design types so invalid states can’t be constructed (03).
// ❌ Fail slow: accept anything, let bad data flow, blow up far from the cause.
// WHY IT'S BAD: a malformed `discount` (e.g. "10%" string, or 1.5) sails through and only
// surfaces three function calls later as a `NaN` total — or worse, a wrong charge in production.
// The stack trace points at the rendering code, not at the bad input that caused it. The bug is
// now expensive to locate because the failure is far in space and time from its origin.
function priceAfter(discount, cents) { return cents * (1 - discount); } // trusts its inputs blindly
// ✅ Fail fast + unrepresentable illegal states: reject bad input AT THE BOUNDARY, and model the
// valid range in the type so the rest of the code can't even receive a bad value.
// WHY IT'S RIGHT: the error fires at the exact point of entry with a clear cause, and downstream
// code is guaranteed clean data — so it needs zero defensive checks. The failure is local and loud.
type Ratio = number & { readonly __brand: 'Ratio' }; // 0..1, validated once at the edge (03)
function toRatio(n: number): Ratio {
if (!(n >= 0 && n <= 1)) throw new RangeError(`discount must be 0..1, got ${n}`); // fail fast
return n as Ratio;
}
function priceAfter(discount: Ratio, cents: number) { return cents * (1 - discount); } // can't be bad
Other durable heuristics
- Tell, Don’t Ask — push behavior onto the object that owns the data instead of pulling its data out to act on elsewhere (the positive form of the Law of Demeter; the same instinct as GRASP’s Information Expert).
- Single Level of Abstraction (SLAP) — within one function, keep every statement at the same conceptual level; don’t mix high-level orchestration with low-level string/byte fiddling. Mixed levels are the usual reason a function “feels” hard to read.
- Design by Contract (Bertrand Meyer) — a function declares its preconditions (what it requires), postconditions (what it guarantees), and invariants (what always holds). Callers honor inputs; the function honors outputs. Pairs with Fail Fast (assert the preconditions) and encapsulation (invariants).
- Orthogonality (Pragmatic Programmer) — unrelated things should be independent: changing one must not affect another. The book’s word for designed-in low coupling.
- Hyrum’s Law — “with enough users of an API, every observable behavior will be depended on by someone, regardless of your contract.” Why even undocumented quirks can’t be removed safely — essential for library/design-system authors (
11). - Gall’s Law — “a complex system that works invariably evolved from a simple system that worked.” You can’t design a large system correctly up front; grow it. The deep justification for start-simple / modular-monolith-first (
25) and YAGNI. - Postel’s Law (Robustness Principle) — “be conservative in what you send, liberal in what you accept.” Handy for tolerant interop — but apply with care: too liberal hides bugs and opens security holes (the modern critique), so pair it with Fail Fast at trust boundaries (
17). - Unix philosophy — small programs that each do one thing well and compose through a uniform interface; the spiritual ancestor of composition-over-inheritance and SRP.
- Boy Scout Rule — leave code cleaner than you found it; continuous small improvement beats big rewrites.
- Premature optimization is the root of all evil (Knuth) — measure before optimizing (
15); clarity first. - Convention over Configuration — sensible defaults reduce decisions (Next’s file-router,
08). - Principle of Least Privilege — give code/users the minimum access they need (
17). - Leaky Abstractions (Spolsky) — all non-trivial abstractions leak; know what’s under yours so you can debug when it does.
- Rule of Three / AHA — don’t abstract until the third real occurrence reveals the true pattern; “Avoid Hasty Abstractions” (Kent C. Dodds) — prefer duplication over the wrong abstraction (the DRY nuance above).
SOLID (the OO design backbone — applies to components too)
Five principles (Robert C. Martin) for change-tolerant object-oriented design. They map cleanly onto React components/hooks and modules. Each gets a tight before/after below.
S — Single Responsibility Principle. A module should have one reason to change (one actor/stakeholder it answers to).
// ❌ Two responsibilities fused: how a report is FORMATTED and where it's PERSISTED.
// WHY IT VIOLATES SRP: it has two reasons to change — a CSV-format tweak and a storage change both
// edit this one function, and you can't unit-test the formatting without hitting the filesystem.
function exportReport(rows: Row[]) {
const csv = rows.map(r => `${r.name},${r.total}`).join('\n'); // formatting concern
return fs.writeFile('/out/report.csv', csv); // persistence concern
}
// ✅ One reason to change each; the pure part is trivially testable.
const toCsv = (rows: Row[]) => rows.map(r => `${r.name},${r.total}`).join('\n'); // formatting only
const save = (path: string, data: string) => fs.writeFile(path, data); // persistence only
// exportReport = (rows) => save('/out/report.csv', toCsv(rows));
“Responsibility” is the most misread word in SOLID — it does not mean “one thing the code does.” For what a responsibility actually is, the responsibility-vs-concern distinction, which entity holds it (a function? a class?), and how it plays out differently in FP vs OOP, see “SRP in depth” right after the SOLID list below.
O — Open/Closed Principle. Open for extension, closed for modification — add behavior without editing existing code.
// ❌ Closed for extension: every new shipping method EDITS this function.
// WHY IT VIOLATES OCP: adding "overnight" means reopening tested code and risking the existing
// branches; the function grows an if-ladder forever and becomes a change magnet.
function shippingCost(method: string, w: number) {
if (method === 'standard') return w * 2;
if (method === 'express') return w * 5 + 10;
throw new Error('unknown method');
}
// ✅ Extend by ADDING data, not editing logic (Strategy pattern, 21).
// WHY IT'S RIGHT: a new method is a new entry in the map; the dispatch code never changes — so you
// can't break the existing methods while adding one. Open for extension, closed for modification.
const rates: Record<string, (w: number) => number> = {
standard: w => w * 2,
express: w => w * 5 + 10,
};
const shippingCost = (method: string, w: number) => rates[method](w); // add 'overnight' → add a key
L — Liskov Substitution Principle. Subtypes must be substitutable for their base type without breaking expectations.
// ❌ Square "is-a" Rectangle via inheritance, but the overrides break the base's contract.
// WHY IT VIOLATES LSP: code written for Rectangle assumes width and height move INDEPENDENTLY
// (set w=5, h=4 → area 20). Substitute a Square and that invariant silently breaks (area 16). The
// subtype is not safely substitutable, so any function taking a Rectangle can now misbehave.
class Rectangle { constructor(public w=0, public h=0){} setW(w){this.w=w} setH(h){this.h=h} area(){return this.w*this.h} }
class Square extends Rectangle { setW(w){this.w=this.h=w} setH(h){this.w=this.h=h} } // breaks the contract
// ✅ Don't force the is-a; model both as shapes behind a common capability.
// WHY IT'S RIGHT: there's no inherited contract to violate — each shape honors only its own. Anything
// that needs `area()` can take any Shape and never be surprised.
interface Shape { area(): number }
const rectangle = (w: number, h: number): Shape => ({ area: () => w * h });
const square = (s: number): Shape => ({ area: () => s * s });
I — Interface Segregation Principle. Don’t force clients to depend on methods/props they don’t use.
// ❌ Fat interface: a read-only display grid is forced to depend on sorting, export, pagination…
// WHY IT VIOLATES ISP: consumers are coupled to capabilities they never use; a change to the
// `onExport` contract ripples into grids that can't even export. One bloated contract, many victims.
interface GridProps { rows; columns; onSort; onFilter; onPaginate; onExport; onResize; /* +many */ }
// ✅ Small, focused interfaces; compose only what a given grid needs.
// WHY IT'S RIGHT: a ReadOnlyGrid depends on exactly `Listable` — nothing else can affect it. Each
// consumer pays only for what it uses, so changes stay narrowly scoped.
interface Listable { rows: Row[]; columns: Col[] }
interface Sortable { onSort(col: Col): void }
interface Exportable { onExport(): void }
// ReadOnlyGrid: Listable. InteractiveGrid: Listable & Sortable & Exportable.
D — Dependency Inversion Principle. High-level policy shouldn’t depend on low-level detail; both depend on an abstraction.
// ❌ High-level policy (the hook) depends directly on a low-level detail (fetch + the endpoint).
// WHY IT VIOLATES DIP: the UI is welded to HTTP and the URL/response shape. You can't swap REST for
// GraphQL, can't mock it cleanly, and backend concerns leak upward into the component layer.
function useOrders() {
return useQuery({ queryKey: ['orders'], queryFn: () => fetch('/api/orders').then(r => r.json()) });
}
// ✅ Both depend on an abstraction the core defines; the concrete impl is injected at the edge (10).
// WHY IT'S RIGHT: the hook knows only the `OrderGateway` interface, so the transport is swappable
// and tests pass a fake. The detail now depends on the abstraction — the dependency arrow inverted.
interface OrderGateway { all(): Promise<Order[]> }
function useOrders(gateway: OrderGateway) {
return useQuery({ queryKey: ['orders'], queryFn: () => gateway.all() });
}
SOLID is guidance for managing change, not a checklist to maximize. Over-applied (an interface for every class, indirection everywhere) it becomes its own complexity problem — balance with KISS/YAGNI. The five letters aren’t independent: SRP creates the small units, ISP keeps their contracts narrow, DIP points their dependencies at abstractions, OCP lets you extend them without edits, and LSP keeps substitution safe — together they push code toward high cohesion, low coupling.
SRP in depth — what a “responsibility” is, and where it lives
A responsibility is a person, not a task. The shift that makes SRP click: a concern is a property of the code; a responsibility is a property of the people the code serves. You can spot a concern by reading the code (“this formats money, this hits the database”). You can only spot a responsibility by asking “if this had to change, whose request would it be?” — and that answer lives in the org chart, not in the source.
Make “reason to change” literal. One function on a checkout page:
function invoiceLine(item) {
const withTax = item.price * 1.10; // tax math
return `${item.name} — R$ ${withTax.toFixed(2)}`; // currency + display text
}
Over a year, three different people walk up to your desk about this one function — Finance (“VAT is now 12%”), Design (“drop the ‘R$’, cleaner prices”), and the Ireland-launch team (“show euros, European formatting”). Each is a reason to change, and each is a different person with a different motivation. That’s all “actor” means: a specific human/team who would ask you to change this code. Fusing them bites the week two of them edit invoiceLine at once — merge conflict at best; at worst the euro change quietly breaks the tax number, so a Finance bug was caused by a localization edit. Split by responsibility and each person’s changes get their own home:
const withTax = (price) => price * TAX_RATE; // Finance owns this
const formatBRL = (n) => `R$ ${n.toFixed(2)}`; // Design / i18n own this
const invoiceLine = (item) => `${item.name} — ${formatBRL(withTax(item.price))}`;
Responsibility ≠ concern — and the proof is that they don’t line up:
- Same kind of work, different people → one concern, two responsibilities:
validateEmail(UX-owned) andvalidateCPF(Legal-owned) are both the “validation” concern, but answer to different actors on unrelated schedules. - Different kinds of work, same person → several concerns, one responsibility: a Finance-owned “monthly report” does data + layout + PDF output (three concerns), yet only Finance ever requests changes.
So: concern = what kind of work (read the code); responsibility = who asks (read the org). They usually coincide, which is exactly why they blur — but SRP’s whole contribution is that the cut that matters most is who asks, because that predicts which changes arrive together, and code that changes together should live together.
Which entity holds the responsibility — a function, a class, or a module? All of them: SRP is recursive. It applies at every grain; only the wording shifts with scale:
- a function → does one transformation (one reason to change). At this grain SRP reads as Clean Code’s “do one thing.”
- a class / module / React component → groups the functions and data that serve one actor. This is Martin’s original target (“one class, one reason to change”).
- a package / service / micro-frontend → owns one bounded context / one team’s domain — which is literally how you draw microservice and MFE seams (
13,09,25; Conway’s Law).
Pick the grain by how change actually arrives: keep editing one function for two unrelated reasons → split the function; one module keeps getting touched by two teams → split the module; one service is owned by two teams → split the service. The smell is always felt during change: “I’m changing X for reason A and keep having to be careful not to break Y, which only exists for reason B.”
Does it differ between FP and OOP? The principle is identical (organize by reason-to-change); the unit it attaches to differs, because the paradigms group code differently:
- OOP bundles data + behavior into a class, so a responsibility attaches to a class: “all the behavior for this concept, for this actor, lives here.” You separate responsibilities into different classes and wire them together by collaboration / dependency injection.
- FP separates data from behavior — plain data, functions that transform it — so a responsibility attaches to a function or a module of functions. Because FP is composite (you build features by composing small functions,
22), single-responsibility is closer to the default: each function does one transformation, and composition combines their behavior without fusing their responsibilities —pipe(withTax, formatBRL)uses both while keeping tax and formatting separate. FP pushes SRP down to the function/module level and leans on composition where OOP leans on classes + injection.
So what counts as a “piece of code” — a “unit” — in this test? Not a fixed size. A unit is anything you could cut out and move somewhere else as one thing — a candidate for extraction: a block of lines you could pull into a function, a function you could move to its own file, a module you could spin into its own package, a slice you could split into its own service. The test works at every zoom level because SRP is recursive; you apply it to whichever clump you’re currently holding:
- a few lines inside a function — point at the tax line → Finance, the format line → Design: two actors in one function body → extract two functions.
- a file / module —
pricing.ts→ the commerce team,CheckoutForm.tsx→ the checkout-UI team: different actors → keep them as separate files. - a service / micro-frontend — checkout vs catalog → different teams → separate deployables (
09,25).
The shortcut: a “piece” is anything you’d give its own name. Naming something declares it a unit — and if two different actors would change it, the honest name has to contain an “…and…”, which is exactly the smell SRP is warning you about.
Either way the pocket test is unchanged: name the human or team who’d request a change to this unit; if you can name two for different parts of it, you’re holding two responsibilities — and sooner or later their changes will fight. (Encapsulation is the separate tool that makes a responsibility’s boundary enforceable rather than merely intended.)
Conway’s Law — why responsibilities end up matching teams
“Any organization that designs a system will produce a design whose structure copies the organization’s communication structure.” (Melvin Conway, 1967.) Empirically, your module, package, and service boundaries drift to match your team and communication boundaries — because code follows the path of least resistance, and that path runs along how people actually talk. Four teams asked to build a compiler tend to ship a four-pass compiler; three frontend teams tend to produce three micro-frontends (09); one team handed a monolith keeps it a monolith.
This is the bridge from SRP to org design. SRP says “one unit per actor” — and at scale, an “actor” is usually a team. Conway’s Law is the observation that aligning units to teams isn’t just good advice, it’s gravity: fight it (one feature that needs five teams to coordinate every release, or one “shared” module five teams all edit) and you get constant friction, merge battles, and ownerless code. Align with it and every unit has a clear owner that changes it for coherent reasons.
The Inverse Conway Maneuver: since structure follows org, deliberately shape the org to get the architecture you want. Want an independently deployable checkout service and catalog service? Give them to two teams with a clean interface between them. Want one cohesive app? Don’t split the team. (See Team Topologies, and the monolith-vs-microservices / SPA-vs-MFE decisions in 25.)
Practical read: when you see architecture fighting the org chart — endless cross-team coordination to ship one thing, or a module nobody owns — the fix is to redraw either the boundaries or the teams so the two line up again.
GRASP — deciding where a responsibility goes
SRP tells you a unit should have one responsibility; it doesn’t tell you which unit should own a given piece of behavior. GRASP (General Responsibility Assignment Software Patterns — Craig Larman) is the missing half: a vocabulary of heuristics for assigning responsibilities. The ones worth knowing:
- Information Expert — give a responsibility to whoever holds the data needed to do it. Put
order.total()onOrder(it owns the line items), not in an externalTotalCalculator. The default move, and the antidote to anemic objects (21). - Creator — the object that contains/aggregates/closely uses B should be the one that creates B.
- Controller — funnel system events into one coordinating object (a use case / app service,
10), not into the UI component. - Low Coupling / High Cohesion — the master goals (above), named here as explicit assignment criteria: of two valid homes, pick the one that lowers coupling and raises cohesion.
- Polymorphism — vary behavior by type through polymorphism, not
switch-on-a-type-tag (the OCP move,21). - Pure Fabrication — when no domain object is a natural home, invent a service object (a
Mapper, aRepository) to keep cohesion high. This is the legitimate reason “service” classes exist. - Indirection — insert an intermediary (gateway, event bus, adapter) to decouple two things that shouldn’t know each other (
10,12). - Protected Variations — wrap the points likely to change behind a stable interface so the change can’t ripple. The principle behind DIP, ports/adapters (
10), and design tokens (11).
GRASP and SOLID overlap heavily: think of GRASP as the vocabulary for the decision (“who should own this?”) and SOLID as the properties of a good answer. (Modern counter-lens: Dan North’s CUPID — Composable, Unix-philosophy, Predictable, Idiomatic, Domain-based — offers “properties over principles” as a softer alternative to SOLID; worth reading as a different framing, not a replacement.)
Worked example: applying the principles to one messy component
// ❌ Violates SRP, SoC, DIP, DRY: fetches, transforms, formats, and renders;
// couples UI to fetch + to the API shape; duplicates currency formatting.
function OrderCard({ id }) {
const [order, setOrder] = useState(null);
useEffect(() => { fetch(`/api/orders/${id}`).then(r => r.json()).then(setOrder); }, [id]);
if (!order) return <Spinner />;
const total = order.lines.reduce((s, l) => s + l.price * l.qty, 0);
return <div>Total: {'R$ ' + (total / 100).toFixed(2).replace('.', ',')}</div>;
}
// ✅ Each concern isolated; each has ONE reason to change.
const formatBRL = (cents: number) => // DRY/SSOT: one formatter
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(cents / 100);
const orderTotal = (o: Order) => // SRP: pure domain rule (10, 22)
o.lines.reduce((s, l) => s + l.price * l.qty, 0);
function useOrder(id: string) { // DIP/SoC: data access behind a hook
return useQuery({ queryKey: ['order', id], queryFn: () => orderGateway.byId(id) });
}
function OrderCard({ id }: { id: string }) { // SRP: render only
const { data: order, isPending } = useOrder(id);
if (isPending) return <Spinner />;
return <div>Total: {formatBRL(orderTotal(order))}</div>;
}
Now a currency-format change touches one function; an API change touches the gateway; a layout change touches the component. Low coupling, high cohesion.
Pitfalls & gotchas
- Over-DRYing coincidental duplication → a wrong, rigid abstraction that couples unrelated code. Wait for the pattern.
- SOLID/abstraction astronaut → interfaces and indirection everywhere; YAGNI/KISS violated in the name of “good design.”
- Treating principles as laws → dogmatic application produces worse code than pragmatic judgment.
- “Clever” code mistaken for skill → KISS says the opposite.
- Hidden side effects (POLA violations) → functions that do more than their name says.
- Duplicated runtime state (SSOT violation) → drift bugs (
06). - Premature optimization → unmeasured “performance” code that adds complexity and often doesn’t help (
15). - God objects / fat props (ISP/SRP violations) → components nobody can safely change.
- Leaky encapsulation (public fields, exposed internal arrays/objects) → invariants get corrupted from afar and you can’t change the internal representation; the bug surfaces nowhere near its cause.
- Splitting by concern but never by actor → tidy folders (
validation/,format/) that still fuse two teams’ reasons-to-change in one file; separation that looks clean but doesn’t actually decouple changes.
Interview questions
- DRY is about repeating what? When is duplication better than abstraction?
- Define coupling and cohesion. What’s the goal, and how do other principles serve it?
- Walk through SOLID with a frontend example for each letter.
- Composition vs inheritance — why does modern frontend favor composition?
- What is YAGNI, and how does it tension with “design for change”?
- Explain the Law of Demeter and what a “train wreck” is.
- What does Single Responsibility actually mean (hint: “reason to change / one actor”)? At what grain does it apply — function, class, or service?
- What’s the difference between a responsibility and a concern? Give a case where they don’t line up.
- How does SRP play out differently in FP vs OOP — and what does composition do for it?
- What is encapsulation, and how is it different from SoC and SRP? (Hint: boundaries vs the wall on the boundary.)
- Why is “premature optimization the root of all evil” — and when should you optimize?
- What’s a leaky abstraction? Give one you’ve hit.
- How can SOLID be over-applied?
- What is Conway’s Law and the Inverse Conway Maneuver, and how does it connect to SRP? (Hint: at scale, an “actor” is a team.)
- In the SRP “pocket test,” what counts as a “piece of code”? (Hint: a candidate for extraction at any zoom level — anything you’d give its own name.)
- What is Command–Query Separation, and how does it relate to CQRS?
- What does GRASP add that SOLID doesn’t? Explain Information Expert and Protected Variations.
- Name the coupling spectrum from worst to best. Which kinds should you push toward?
- What is Hyrum’s Law, and why does it matter for design-system/library authors?
- What is Gall’s Law, and which architecture decisions does it support?
Recommendations
- Optimize for high cohesion, low coupling; treat every other principle as a tactic toward that.
- Apply KISS + YAGNI as the default; reach for abstraction only when the third real case proves the pattern.
- Use SOLID as change-management guidance, balanced against simplicity — not a box-ticking exercise.
- Prefer composition (functions, hooks, components) over inheritance.
- Keep one source of truth for every fact and piece of state.
- Validate/fail at boundaries; make illegal states unrepresentable with types (
03). - Leave code cleaner than you found it; refactor continuously rather than in big-bang rewrites.
Books & references
- “The Pragmatic Programmer” (20th-anniversary ed) — Hunt & Thomas. Origin of DRY, orthogonality, and the practical-wisdom canon. Start here.
- “Clean Code” — Robert C. Martin. Naming, functions, SRP-at-the-method-level (read critically — some advice is debated, but the instincts are valuable).
- “Clean Architecture” — Robert C. Martin. The book-length treatment of SOLID and component principles (
10). - “A Philosophy of Software Design” — John Ousterhout. Deep, modern, and partly a counterpoint to Clean Code; “deep modules,” complexity, and why some Clean Code advice misfires. Highly recommended pairing.
- “Refactoring” (2nd ed) — Martin Fowler. Code smells (the symptoms these principles cure) and the mechanics of fixing them.
- “99 Bottles of OOP” / Sandi Metz talks (“The Wrong Abstraction,” “SOLID”) — the best explanation of when not to DRY.
- “Applying UML and Patterns” — Craig Larman. The source of GRASP and the clearest treatment of responsibility assignment.
- “Object-Oriented Software Construction” — Bertrand Meyer. The origin of Command–Query Separation and Design by Contract.
- Dan North — “CUPID: for joyful coding” (dannorth.net) — the modern “properties over principles” counter-lens to SOLID.
- “The Mythical Man-Month” — Brooks. Conceptual integrity and why complexity kills projects.
Connections
21-oop-foundations.md— SOLID, composition-over-inheritance, and design patterns in their OO home.22-functional-programming.md— purity, immutability, and composition as the FP expression of these principles.10-frontend-architecture.md— SoC/SRP/DIP scaled up into hexagonal/clean/DDD.25-architecture-decisions-and-tradeoffs.md— Conway’s Law and the Inverse Conway Maneuver applied to monolith-vs-microservices and SPA-vs-MFE decisions.03-typescript.md— making illegal states unrepresentable; types enforce ISP/LSP.05-react-internals-and-patterns.md— composition, OCP, and SRP applied to components/hooks.06-state-management-and-stores.md— Single Source of Truth for runtime state.15-performance-and-core-web-vitals.md— “measure before optimizing” in practice.