Previous: SOLID Principles

DRY & The Art of Abstraction

DRY is about knowledge, not keystrokes — and the most expensive code in most systems is a shared abstraction that never should have existed.

What DRY Actually Says

DRY — Don't Repeat Yourself — comes from The Pragmatic Programmer, and the original wording matters: "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system." Notice what that sentence does not say. It does not say "never write similar-looking code twice." It says every piece of knowledge — a business rule, a policy, a fact about the domain — should live in exactly one place.

The distinction sounds pedantic until you realize it inverts how most engineers apply the principle day to day. The common reading is textual: "these two functions look the same, so one of them must go." The correct reading is semantic: "these two functions encode the same rule, so a change to the rule should only require a change in one place." Text similarity is neither necessary nor sufficient evidence of duplicated knowledge.

Both directions of the mismatch show up constantly in real code:

  • Identical code, different knowledge. A shipping fee calculation and a sales tax calculation might both be "multiply by a rate and round to two decimals" today. They are still two separate business rules, owned by different stakeholders, changing for different reasons. Merging them creates a false statement about your domain: that shipping and tax are the same concept.
  • Different code, same knowledge. The rule "an order over 500 dollars requires manager approval" expressed once as a guard clause in the checkout service and once as a filter in a reporting query is a genuine DRY violation, even though the two code paths share zero characters. When finance raises the threshold to 750, someone has to remember both places — and someone eventually will not.

So the operational question is never "does this code look like that code?" It is: "if the business changed its mind about this rule, how many places would I have to edit, and would anything tell me if I missed one?" If the answer is "more than one, and no," you have a DRY violation. If two visually identical functions would change for unrelated reasons, you have a coincidence — and coincidences should not be refactored into commitments.

What interviewers listen for

When a candidate says "I'd extract this into a shared helper," the follow-up I always ask is: "why do these two call sites change together?" Strong candidates answer in terms of ownership and change drivers — "both encode the refund-eligibility policy, so they must move in lockstep" — not in terms of line counts saved. Naming the distinction between duplicated knowledge and coincidentally similar code is one of the fastest signals of senior design judgment in a design interview.

Incidental Duplication: The Trap That Looks Like Virtue

Incidental duplication is when two pieces of code are textually similar by coincidence — they happen to perform the same computation right now, but they represent different knowledge with different owners and different futures. It is the single most common way DRY gets weaponized against a codebase, precisely because merging the copies feels like diligence.

Take the canonical case: your billing module computes sales tax as amount times rate, rounded to two decimal places. Your shipping module computes the shipping fee as — look at that — amount times rate, rounded to two decimal places. A well-meaning engineer spots the "duplication," extracts applyRate(amount, rate), and both call sites now share one function. The diff is beautiful. The code review says "nice cleanup."

Then reality arrives on two independent schedules:

  • Finance needs tax rounding to switch to half-even (banker's) rounding for compliance in a new jurisdiction.
  • Logistics wants shipping fees capped at 25 dollars for the holiday promotion.

Now the shared function needs to behave two different ways. The typical fix is a parameter: applyRate(amount, rate, mode). Then a cap argument. Then a flag for whether the cap applies before or after rounding. Each caller passes a configuration describing which business rule it actually wanted — which is a long-winded way of saying the two rules were never one rule. The merge did not remove knowledge duplication; it manufactured coupling between two teams who now share the fate of one function.

The senior move is to leave the two functions separate and say why, so the next reader does not "fix" it:

// Shipping fees and sales tax are both "amount * rate,
// rounded to 2dp" TODAY. They stay separate on purpose:
// shipping is owned by logistics, tax by finance, and the
// two rules change for unrelated reasons.

function calculateShippingFee(subtotal: number, rate: number): number {
  const fee = subtotal * rate;
  return Math.round(fee * 100) / 100;
}

function calculateSalesTax(subtotal: number, rate: number): number {
  const tax = subtotal * rate;
  return Math.round(tax * 100) / 100;
}

// Six months later, only ONE of them changes -- and the
// change touches nothing it does not own:
function calculateShippingFeeWithCap(subtotal: number, rate: number): number {
  const fee = Math.min(subtotal * rate, 25.0); // holiday cap
  return Math.round(fee * 100) / 100;
}

Don't merge on textual similarity

Never justify an extraction with "these two functions are identical." Justify it with "these two call sites must change together, and here is the business reason why." If you cannot name the shared rule and the stakeholder who owns it, the similarity is incidental and the merge is a future bug with extra steps.

The Wrong Abstraction

Sandi Metz gave the industry its most useful sentence on this topic: duplication is far cheaper than the wrong abstraction. The reason the wrong abstraction is so expensive is that it does not arrive wrong. It arrives clean, passes review, and then decays through a sequence so predictable you can watch for it in any codebase more than a year old.

The Decay Timeline

  • Step 1 — the clean extraction. Two call sites share code; someone factors out a small, well-named shared function. At this moment it is genuinely good.
  • Step 2 — the almost-fit. A new requirement arrives that is nearly what the abstraction does. The author of the new feature sees the existing function and — reasonably — wants to reuse rather than duplicate.
  • Step 3 — the parameter. Rather than duplicating, they pass a flag: one boolean, defaulted so existing callers are untouched. The diff is small. Review approves it.
  • Step 4 — the conditionals. The next almost-fit adds another flag, and flags start interacting. Branches inside the function now exist for specific callers, but nothing says which. The function's name no longer describes what it does, because it does five different things.
  • Step 5 — the fear. Nobody can hold all the flag combinations in their head, so nobody refactors it. New requirements get bolted on with more flags because that is the established pattern. The abstraction is now load-bearing, unreadable, and effectively frozen.

The critical insight is that every individual step was locally reasonable. Step 3 in particular is where the trap closes: existing code exerts gravity, and reusing it feels responsible. The engineer who duplicates instead looks like they are ignoring the codebase. This is why the wrong abstraction is a systems failure, not a competence failure — it is produced by good engineers making defensible choices under a norm that says duplication is always bad.

Here is step 4 in the wild, in a notifications module:

// The "shared" send function, three requirements later.
// Every parameter after message exists for ONE caller.
function sendNotification(
  userId: string,
  message: string,
  isInvoice: boolean,
  skipQuietHours: boolean,
  ccAccountManager: boolean
): void {
  let subject = "Notification";
  let body = message;
  if (isInvoice) {
    subject = "Invoice ready";
    body = body + " View it in the billing portal.";
  }
  if (!skipQuietHours && isWithinQuietHours(userId) && !isInvoice) {
    enqueueForMorning(userId, subject, body);
    return;
  }
  deliver(userId, subject, body);
  if (ccAccountManager && isInvoice) {
    deliver(accountManagerFor(userId), "FYI: " + subject, body);
  }
}

How to Back Out

Metz's prescription is the part most people have never actually seen executed, so here it is as a mechanical procedure. When you inherit a flag-infested abstraction:

  • Inline it into every caller. Copy the full function body into each call site, substituting that caller's flag values as constants.
  • Delete dead branches per copy. With the flags now constant, most conditionals in each copy are statically decidable. Remove every branch that can never execute for that caller. Each copy shrinks dramatically.
  • Delete the shared function. It has no callers left. This is the step that takes nerve, because the diff shows "duplication increasing." That is the point: you are trading hidden complexity for visible repetition.
  • Let the copies diverge. New requirements now land in exactly the copy they belong to, with no risk to the others.
  • Re-abstract later — maybe. If, after the dust settles, a genuine shared concept remains across the copies, extract that. It will be a different, smaller abstraction than the one you deleted, because now it is shaped by real requirements instead of speculative ones.

The same notifications module after backing out:

// Inlined, dead branches deleted, shared function removed.
// Each sender now states its whole policy in one screen.

function sendInvoiceReadyNotification(userId: string): void {
  const subject = "Invoice ready";
  const body = "Your invoice is available in the billing portal.";
  // Invoices always send immediately and always cc the AM.
  deliver(userId, subject, body);
  deliver(accountManagerFor(userId), "FYI: " + subject, body);
}

function sendShipmentUpdate(userId: string, status: string): void {
  const subject = "Shipment update";
  const body = "Your order status changed to " + status + ".";
  // Shipment updates respect quiet hours.
  if (isWithinQuietHours(userId)) {
    enqueueForMorning(userId, subject, body);
    return;
  }
  deliver(userId, subject, body);
}

Notice what the after-version costs: a few repeated calls to deliver. Notice what it buys: each function is now a complete, honest statement of one notification policy. A new engineer can read either one in ten seconds and change it without reading the other. That trade — visible repetition for independent evolvability — is almost always worth it once an abstraction has started accumulating caller-specific flags.

The Rule of Three and AHA

Two heuristics give you a practical default for when to abstract. The Rule of Three (popularized via Martin Fowler's Refactoring) says: write it once, copy it a second time wincing, and only on the third occurrence extract the abstraction. AHA — Avoid Hasty Abstractions, Kent C. Dodds's framing — makes the underlying preference explicit: "prefer duplication until the abstraction becomes obvious," because optimizing for the wrong abstraction is more expensive than either duplication or the right one.

Why does waiting work? Because of an asymmetry in how the two failure modes are repaired:

  • Duplication is visible and mechanically fixable. All the copies are sitting in the open. Your editor, grep, or a duplicate-detection linter can find them, and extracting a shared function from three concrete, battle-tested copies is a safe, almost mechanical refactor. Crucially, three real copies show you the abstraction's true shape: which parts actually vary and which are genuinely fixed.
  • A bad abstraction is invisible and politically expensive. The copies it should have been are gone — compressed into flags and branches — so you cannot see what each caller really needs without reverse-engineering it. Fixing it requires the inline-and-split procedure above, a diff that increases line count and looks like regression to reviewers. Duplication is a debt you can see on the balance sheet; the wrong abstraction is a debt hidden inside an asset.

The Rule of Three is a default, not a law — and the exception runs in the strict direction too. When the duplicated thing is genuine knowledge with a single owner, two copies is already one too many. If the same authorization check, the same money-rounding policy, or the same input-sanitization routine exists in two places, you do not wait for a third: divergence there is not a maintenance annoyance, it is a security hole or a ledger that does not balance. The test is the same one from the first section — is this one rule or two? — the Rule of Three only governs the cases where the honest answer is "not sure yet."

A cheap trick while you wait

When you deliberately keep a second copy, leave a breadcrumb: a one-line comment in each copy naming the other — "intentionally similar to calculateSalesTax; separate rules, see module docs." It costs nothing, it stops the next engineer from "deduplicating" your judgment call, and if the copies ever do need to merge, the breadcrumb is your inventory of call sites.

Coupling and Cohesion

"Low coupling, high cohesion" is repeated so often it has worn smooth. Definitions that actually discriminate:

  • Coupling is how much one module must know about — and therefore how likely it is to change because of — another module. It is measured in change propagation: when B changes, does A have to?
  • Cohesion is how much the parts inside a module belong to one another — whether they change for the same reason and serve one purpose. A module that handles invoice formatting, retry backoff, and timezone math has three purposes and low cohesion, no matter how tidy each function is.

Here is the connection to everything above: DRY applied to incidental duplication raises coupling. The moment shipping and tax share applyRate, the shipping team and the tax team share the fate of one function. Every caller of a shared abstraction is coupled to it — and, through it, to every other caller's future requirements. That is a fine price when the callers share one piece of knowledge and must change together anyway. It is pure loss when they do not. Reuse is not free; reuse is coupling, purchased on purpose.

The classic structured-design literature (Stevens, Myers, and Constantine) ranked kinds of coupling. The vocabulary is old but the ranking is still the sharpest code-review checklist available:

Kind (worst to best)What it meansRecognizable as
Content couplingOne module reaches into another's internals — private state, internal branches, monkey-patching.Reading a sibling service's database tables directly; patching a library's private method in production code.
Common (global) couplingModules communicate through shared mutable global state, so any writer can break any reader invisibly.A mutable global config object that request handlers both read and write; module-level mutable caches.
Control couplingOne module passes a flag that tells another which behavior to perform, so the caller knows the callee's internal branching.Boolean flag arguments; mode strings; the sendNotification function above.
Stamp couplingA module receives a whole structure but uses only a few fields, so it appears dependent on the entire shape.Passing a full Order object to a function that only needs the currency code and total.
Data couplingModules exchange only the simple data they actually need. The healthy baseline.calculateSalesTax(subtotal, rate) — two numbers in, one number out.

Control coupling deserves special attention because it is the signature smell of a decaying abstraction. A boolean flag argument means the caller has to know that the callee has two behaviors and which one it wants — the callee's internal structure has leaked into every call site. The standard cure is to split the function in two along the flag and let each caller invoke the one it means. If the flag came from merging two callers' needs (step 3 of the decay timeline), the split is really an un-merge: the flag was the seam where two pieces of knowledge were stitched together, and it is showing you exactly where to cut.

Flags are a coupling smell, not a style nit

When a code review flags a boolean parameter, the objection is not aesthetic. A flag argument is control coupling: it forces every caller to know the callee's internal branches, it makes call sites unreadable (what does "true, false, true" mean?), and it is the mechanism by which wrong abstractions grow. Treat a new flag on a shared function as a design decision requiring justification, not a default.

KISS, YAGNI, and Speculative Generality

The wrong abstraction has a twin that arrives from the opposite direction. The decay timeline starts with real duplication and over-merges it; speculative generality starts with nothing — it builds flexibility for requirements that do not exist. KISS ("keep it simple") and YAGNI ("you aren't gonna need it," from Extreme Programming) are the antibodies. YAGNI's precise claim is not "never think ahead" — it is: do not build the capability until the requirement is actual, because you will build it better, cheaper, and correctly-shaped when you can see the real need.

The smell has recognizable field markings:

  • A "rules engine" or "workflow framework" with exactly one rule or workflow ever configured, wrapped around what could have been an if-statement.
  • Configuration options that have had one value in every environment since launch — each one a parameter someone must understand, document, and test, guarding a choice nobody has ever made.
  • An interface with a single implementation, created "so we can swap it later," for a dependency (like the relational database) that has never once been swapped.
  • "We might need multi-tenancy" — tenant ID columns, scoping middleware, and partitioning logic threaded through a system with one tenant and no second customer on any roadmap.

The cost accounting is what makes YAGNI a hard-nosed economic argument rather than a taste preference. Speculative flexibility is a loan: you pay the build cost now, out of time that could ship features someone asked for; you pay carry cost continuously, because every reader of the code must understand the indirection whether or not it ever pays off, and every test matrix multiplies across options nobody uses; and you pay a repair cost at the end, because when the real requirement finally arrives it almost never matches the guessed shape — so you pay to remove or contort the speculative version first. You pay the interest whether or not you ever draw on the flexibility. The only winning move is not to take out the loan: write the simple version, keep it well-factored so change stays cheap, and add the generality on the day a real requirement defines its shape.

Note the common thread with everything above: the wrong abstraction, incidental deduplication, and speculative generality are all the same mistake at different scales — committing to a structure before the knowledge that justifies it exists. Duplication you can see; simplicity you can extend. Both are recoverable positions. A confident structure built on a guess is the position that costs the most to retreat from.

The one-sentence summary

Deduplicate knowledge, tolerate coincidence, abstract on evidence rather than prophecy, and treat every shared function as a coupling contract you are asking all of its callers to sign.

Knowledge Check

Test your understanding before moving on.

Loading quiz...