Engineering Atlas › Low-Level Design

Don't repeat yourself (DRY)

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The definition is about knowledge

The phrase comes from The Pragmatic Programmer (Hunt and Thomas, 1999), which defines it as "every piece of knowledge must have a single, unambiguous, authoritative representation within a system". The key words are knowledge and authoritative. A shipping-weight limit of 30 kg is knowledge. If it appears as 30 in the API validator, as 30000 grams in the warehouse service and as "up to 30 kg" in the help page, the system holds three copies of one fact, and nothing forces them to agree.

Code, schemas, configuration, documentation, tests and database constraints can all hold knowledge. DRY applies to all of them, which is why a principle that sounds like "extract a function" is really about where facts live.

What drift looks like

# checkout/api.py
def can_ship(weight_g):
    return weight_g <= 30_000

# warehouse/labels.py
MAX_PARCEL_KG = 30
def label_class(weight_kg):
    return "heavy" if weight_kg > MAX_PARCEL_KG * 0.8 else "standard"

Then the carrier raises the limit to 32 kg. The team updates MAX_PARCEL_KG, and checkout keeps rejecting parcels between 30 and 32 kg for a month. Each copy was correct when written. The defect is that they were copies.

The fix is one owner of the fact, shipping_limits.MAX_PARCEL_GRAMS = 32_000, imported by both. When the services are separate deploy units, the owner might be a config service, a shared schema, or one service exposing the limit through its API.

Coincidental duplication

Two functions can be textually identical and still represent different knowledge:

def free_shipping_eligible(order_cents):
    return order_cents >= 5_000

def loyalty_bonus_eligible(order_cents):
    return order_cents >= 5_000

Marketing owns one threshold, the loyalty team the other. Merging them into over_fifty_dollars() couples two policies that will diverge next quarter. The test is the reason to change: if the two would always change together, they are one piece of knowledge; if they change for different reasons and by different people, they are two, however alike they look.

The wrong abstraction

Sandi Metz describes the usual decay: someone extracts a shared helper; a new caller needs something slightly different, so a parameter is added; then a flag; then a branch for a third caller. After a year the helper is a switch statement over callers, each change risks the others, and nobody dares touch it. Her advice is to inline the abstraction back into its callers and let them diverge, then look again for what is truly shared. The rule of three is the practical version: tolerate the second copy, extract on the third, when you can see what actually varies.

Generate instead of copy

Some knowledge must exist in several forms: a request format appears as server validation, client types, API docs and a database column. Writing each by hand is four copies. Generating them from one definition (Protocol Buffers, OpenAPI, JSON Schema, an ORM model) keeps one authoritative source and lets tools produce the rest. The practice task builds a small version: one field schema that drives both validation and documentation.

Reviewing agent-written code

A coding agent asked to add a "premium" plan often copies the "standard" plan's pricing function and edits two numbers. That is duplication of the rounding and proration rules, which will drift. Asked to "reduce duplication", the same agent may merge two unrelated validators behind a mode parameter. Ask, for each copy or each merge: what is the fact here, who owns it, and will these change together?

The same shape elsewhere

DRY is the multi-step consistency problem in source code. Two copies of a fact behave like two replicas of a database row without replication: they agree at write time and diverge silently afterwards. The remedies are the same as in data systems. Keep one leader (a single source of truth), derive the rest from it (generation, like a materialised view), or detect divergence (a test that compares the copies, like an anti-entropy check).

Down the stack

Layer 1The contract

A DRY design promises that changing a piece of knowledge requires editing one place, and that every consumer sees the change after the next build or deploy. Testable form: grep for the fact (the literal, the regex, the field name) and find one definition; change it and watch every dependent test respond. The flip side of the contract is coupling: every consumer of the shared definition now changes when it changes, whether it wanted to or not.

Layer 2The mechanism

Languages offer several ways to keep one copy, each with a different cost. A function is the simplest. Generics remove duplication across types, and are implemented very differently: C++ templates and Rust generics are monomorphised, meaning the compiler emits a separate machine-code copy per type; Java erases type parameters to one shared body that works on Object references; Go uses "GC shape stenciling", producing one body per memory shape (all pointer types share one) plus a dictionary argument that carries type information. Code generators (protoc, OpenAPI generators, sqlc) produce source from a schema at build time. Shared libraries move a definition into a separate package that several programs import.

Each of these adds an edge to the build graph. In a monorepo built with Bazel or a similar tool, a change to a widely shared package invalidates every target that depends on it, so editing one constant in a common library can rebuild and retest thousands of targets. Across services, a shared library ties deploy units together: to change the shared model, every service that embeds it must be rebuilt and redeployed, or run with mismatched versions in the meantime.

Layer 3The machine

Extracting a helper adds a call: a few nanoseconds at most, and usually zero, because compilers inline small functions. Go inlines functions under a cost budget of roughly 80 nodes of its intermediate representation; HotSpot inlines hot methods up to a bytecode size threshold of a few hundred bytes. So the compiler often undoes your DRY at the machine level by copying the helper into each caller, which is fine: duplication in machine code is cheap and does not drift.

Monomorphisation is the same trade made deliberately. Each instantiation of a C++ template gets its own specialised, fast code, at the price of binary size and instruction-cache pressure; a heavily templated library can add megabytes to a binary. Go's shape-based approach keeps binaries smaller but routes some method calls through the dictionary, which can be slower than a direct call in hot loops. Dynamic linking is DRY at the operating-system level: one copy of libc lives in the page cache and is mapped read-only into every process, so 200 processes share one set of physical pages instead of 200.

Why it's built this way

  1. Facts change, and every copy of a fact must change with it, so copies multiply the cost of change and the risk of missing one.
  2. So keep one authoritative copy and derive the others.
  3. But every derivation is a dependency, and dependencies couple the things that share them.
  4. So apply DRY to knowledge that genuinely changes together, and accept duplicated text, or let the compiler duplicate machine code, where coupling would cost more than drift.

Diagram

flowchart LR
  S["order.schema (one source)"] --> G["code generator"]
  G --> V["server validator"]
  G --> T["client types"]
  G --> D["API docs"]
  G --> M["DB migration"]
  V --> API["checkout service"]
  T --> WEB["web app"]
  CHK["CI: regenerate and diff"] --> S

Worked example

An online store computes discounts in two places: the TypeScript cart shows a price as the shopper builds an order, and the Go payment service charges the card. Both implement "10 percent off orders over 100 dollars, rounded to the cent". The cart rounds each line item and sums; the payment service sums and then rounds. For three items at 33.35 dollars, the cart discounts each line to 30.015, rounds it to 30.02 and shows 90.06; the payment service discounts the 100.05 total to 90.045, rounds once and charges 90.05. Across 40,000 orders a day, about 0.6 percent end with the displayed and charged totals a cent apart: around 240 orders and a steady trickle of support tickets asking why the price changed at payment.

The team considered sharing a library between the two codebases, which is awkward across languages and would couple the web release to the payment release. They chose a single source of truth instead: the payment service exposes a quote endpoint that returns the exact total, and the cart calls it on each change (about 20 ms per call). The discount rule now exists once. For the offline preview, the cart shows "about 90 dollars" until the quote returns. Mismatch tickets went to zero, and the next pricing change touched one file.

Trade-offs

OptionStrengthsWeaknessesChoose when
Duplicate the codeNo coupling, each copy evolves freelyCopies drift when the knowledge is sharedThe copies encode different knowledge, or you have only two
Extract a function or moduleOne place to change; cheap after inliningCouples every caller to one definitionSame knowledge, same reason to change, same deploy unit
Generate from a schemaOne source across languages and layersBuild step, generator quirks, generated code to readFormats shared across services, clients and storage
Shared library across servicesReal code reuseCouples deploy units; version skewStable, slow-changing utilities (logging, auth clients)
One service owns the factSingle runtime source of truthNetwork hop, availability dependencyBusiness rules such as prices, limits and entitlements

Pitfalls in production

Leadership lens

Check yourself

  1. Two functions have identical bodies. Under what condition should you leave them duplicated?

    Show answer

    When they encode different knowledge that changes for different reasons or is owned by different people. Merging them would couple policies that will diverge.

  2. Why can monomorphised generics increase binary size, and why is that not a DRY violation?

    Show answer

    The compiler emits a separate copy of the code for each type instantiation. The source still has one definition, so there is one place to change; the machine-code copies are derived and cannot drift.

  3. A constant shared through a library used by 12 services needs to change. What does DRY cost you here?

    Show answer

    Every service must rebuild and redeploy to pick up the change, and until then services disagree. If the value changes often, it belongs in configuration or behind one service's API instead.

  4. What is the rule of three, and what problem does it guard against?

    Show answer

    Allow a second copy and extract a shared abstraction only at the third. It guards against premature abstraction built from too few examples to see what actually varies.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →