The Rule of Three
A heuristic about evidence, not a counting rule. Duplicate until the pattern is a pattern, because the third case is usually the first one that shows you which parts actually vary.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
How much evidence should I have before turning duplication into an abstraction?
A CSV export exists for orders. Someone asks for a CSV export of customers. It is 80% the same code and the obvious move is to extract a shared exporter.
Two is enough. Extract now, while both are fresh in mind and before they drift. Waiting means writing the same code twice and then paying to merge it later, which is strictly more work.
With two cases you cannot tell which differences are essential and which are incidental, so the extracted shape is a guess — and the guess is usually "everything that differs is a parameter" (Premature Abstraction).
- With two cases you cannot tell which differences are essential and which are incidental, so the extracted shape is a guess — and the guess is usually "everything that differs is a parameter" (Premature Abstraction).
- The third case almost always differs along an axis neither of the first two hinted at: streaming for a large dataset, a per-tenant column set, a different escaping rule. Then the abstraction grows a flag (Choosing the Model).
- Merging creates a coupling between two teams' release schedules that nobody declared and that shows up as a surprise later (Agreement Costs Round Trips).
- Meanwhile the cost of waiting is small and visible — some duplicated lines — while the cost of a wrong merge is large and invisible, because the coupling it creates is not written down anywhere (The Cost of Change).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The two exports are written by different teams with different deadlines; a shared component means a shared review queue.
- The first export has been in production for a year and has been changed twice, both times for order-specific reasons.
- The team has one shared
Exporterfrom a previous round of this, and it currently has four boolean parameters (Boolean Parameters).
- Whatever is shared must be shared because it encodes the same knowledge, not because it looks alike (Duplicate Knowledge).
- A change requested for one export must never silently change the other.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The engineer writing the second case owns noticing the similarity and recording it — a comment naming the other site is enough (Docs Close to Code).
- The third case owns the decision. By then there is enough evidence to say which parts are the same knowledge and which merely rhyme.
- Whoever extracts owns proving that all three change together, not that all three look alike.
- The boundary is between shared *knowledge* and shared *shape*. Two functions that both iterate rows and write commas share a shape; two that both encode "how this company formats an exported date" share knowledge (DRY: Knowledge, Not Lines).
- The test is a question about the future, and it is answerable: when this rule changes, must both places change? If yes, one thing. If no, leave them (Duplicate Knowledge).
- Three is a threshold on evidence, not on instances. Two cases that obviously encode one business rule justify extraction immediately; five that merely look alike never do.
What each occurrence is actually for
The rule is usually taught as "wait until three", which makes it sound like patience for its own sake. It is not: each occurrence has a job, and the job of the third is to reveal which differences are essential.
Skipping to the extraction at two is not impatience, it is inference from a sample of two — where every difference looks incidental and every similarity looks structural, and there is no way to tell which is which.
- 1First: write it
Solve the problem concretely, with no generality at all. This code is allowed to be specific — it is the only case that exists.
fails by Building a framework for one case, which is the purest form of speculative generality (Speculative Generality).
- 2Second: duplicate, and record
Copy it, adapt it, and leave a comment naming the other site. The duplication is deliberate and the note is what makes it recoverable.
fails by Extracting here — with a sample of two, the parameter list is a guess — or copying with no note, so the third engineer never learns the first two exist.
- 3Third: compare all three
Lay them side by side. Mark what is identical, what varies, and along which axis. That comparison is the design input the first two could not provide.
fails by Comparing only the newest two, which reproduces the sample-of-two problem with extra steps.
- 4Decide: same knowledge?
Ask whether a change to the shared part must change all three. If yes, extract exactly that part. If no, leave them alone — permanently.
fails by Extracting because the code looks alike, which merges two unrelated policies into one unit with two owners (Duplicate Knowledge).
- 5Extract the identical part only
Pull out what all three share. Leave the specifics where they are, in each caller.
fails by Absorbing the surrounding code too, so the shared unit needs a mode flag on day one (Boolean Parameters).
- 6Watch the parameter list
Revisit when a fourth case forces a new parameter. Growth is the signal that these were not one thing after all.
fails by Adding the parameter without noticing, which is how a shared exporter acquires four booleans (Long Parameter List).
The threshold is on evidence rather than on instances. Two copies of a business rule are one piece of knowledge and should be merged at once; five superficially similar validators for unrelated concepts should stay five forever.
Same shape, different knowledge
The two functions below are nearly identical, and merging them is the mistake this heuristic exists to delay. They are the same shape because CSV is the same shape; they encode different decisions made by different people for different reasons.
The version on the right is what the rule actually asks for: extract the part that is genuinely one piece of knowledge — here, how this company escapes and formats a CSV cell — and leave both callers owning their own column choices.
function exportCsv(rows: any[], opts: {
includeArchived?: boolean // orders only
redactEmails?: boolean // customers only
dateFormat?: 'iso' | 'local' // differs, for unrelated reasons
chunked?: boolean // added later for the third case
}) { /* ... */ }
// A change to order columns now requires reasoning about
// customer exports, GDPR redaction, and the streaming path.// export/csv.ts — one decision: how this company writes a CSV cell export function csvLine(cells: string[]): string export function csvCell(v: unknown): string // escaping, null, dates // orders/export.ts const line = csvLine([o.id, o.placedAt.toISO(), money(o.total)].map(csvCell)) // customers/export.ts const line = csvLine([c.id, redact(c.email), c.country].map(csvCell)) // Column choices stay with whoever owns them.
Escaping rules are one piece of knowledge — if the company decides to quote every field, both exports must change together, so they belong in one place. Column selection is two pieces of knowledge with two owners: a GDPR decision about customer emails must never be able to alter the orders export. The merged version couples those two owners through a parameter list, and the coupling is invisible at both call sites, which is what makes the eventual bug silent (DRY: Knowledge, Not Lines).
What the wait costs, and what it buys
The argument for extracting early is that waiting wastes work. That is true and the amount is measurable: some duplicated lines and one extra edit if the shared part changes before the third case arrives.
The argument for waiting is that the third case changes the design. Below, it does — the third export is large enough to need streaming, which no interface derived from the first two would have anticipated.
A transactions export, 40 million rows, must stream to object storage rather than build a string in memory.
The shared function now has two internal modes that share almost no code, plus a flag to choose between them. Both existing callers are re-tested for a change neither of them asked for, and the next reader of exportCsv has to hold both modes in mind.
The streaming export is a separate function that reuses the two-line cell-formatting primitives. Neither existing export is opened, because nothing about them changed.
How to build it
Most important first.
- Write the second copy. Deliberately, without guilt, and with a comment naming the first — the comment is what turns duplication into a recorded observation rather than an accident.
- When the third arrives, lay all three side by side and mark what genuinely differs. That list is the abstraction's parameter list, and it is now derived from evidence (What an Abstraction Actually Is).
- Extract only the part that is identical across all three. Resist pulling in the surrounding code because it is nearby (Over-Decomposition).
- If the extraction needs a boolean or a mode flag to fit all three, that is the signal that they are not one thing — stop and keep them separate (Boolean Parameters).
- Prefer extracting a small pure helper over a framework the callers must fit into. A function all three call beats a base class all three extend (Composition Over Inheritance).
- Where a change is genuinely expensive to retrofit, ignore the heuristic and extract at one. The rule is about ordinary code, not about seams that touch stored data (What an Abstraction Costs).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Waiting: the cost is two copies of some code and one extra edit if a shared aspect changes before the third case. Small, bounded, and visible in a diff.
- Extracting at two, wrongly: every subsequent change to either caller goes through a shared unit that must satisfy both, so a one-line order-export change requires reasoning about customer exports too. That is the cost that compounds (Change Amplification).
- Extracting at three, correctly: the fourth export is a call with a config value, and — the part that matters more — a change to the shared formatting rule is one edit rather than four, which is the actual return on the wait.
- Waiting means occasionally paying to merge three copies that had already drifted, and reconciling drift is fiddly work nobody enjoys.
- It means living with duplication that reviewers will flag, repeatedly, unless the team has agreed the heuristic explicitly.
- Three is arbitrary as a number, and defending an arbitrary number is uncomfortable. Its real job is to buy time for evidence to arrive, and any threshold that does that would serve.
What can go wrong
- The third case arrives, does not fit, and is forced through the abstraction with a flag rather than triggering a rethink (Premature Abstraction).
- The rule is applied to knowledge, not shape: a tax rule is written twice because "it is only two places", and the two drift (Duplicate Knowledge).
- Counting replaces judgement, and a fourth genuinely-different case gets merged because the number said three.
- The mitigation fails when the comment naming the other copy is never written, so at the third case nobody knows the first two exist and there is nothing to compare (Comments).
- Extraction creates a dependency from every caller onto the shared unit, and thereby a coupling between callers that previously had none (Fan-in and Fan-out).
- That coupling is organisational as well as technical: two teams now share a review queue, a release cadence and a blast radius (Code Ownership).
- Duplication has its own dependency — on someone noticing both copies when the rule changes — which is exactly what makes duplicated *knowledge* dangerous and duplicated *shape* harmless.
- "Never abstract until the third copy." It is a heuristic about evidence. Two copies of a tax rule are one piece of knowledge in two places and should be merged immediately (DRY: Knowledge, Not Lines).
- "Duplication is fine." Duplicated knowledge is a defect waiting for a rule change; duplicated shape is cheap. The heuristic depends entirely on which one you have (Duplicate Knowledge).
- "Count the copies." Counting is a proxy for evidence, and evidence is the thing. Five near-identical validators for five unrelated concepts should stay five (Cohesion).
- "Extract as soon as it is convenient." Convenience is about the author; the coupling created is paid by callers who never asked for it (Kinds of Coupling).
- duplicate-knowledge
- long-parameter-list
Testing it, and how it ages
- Before extracting, make sure each existing case has a test that captures its current behaviour, so the merge is verifiably behaviour-preserving (Characterization Tests).
- After extracting, each caller keeps a thin test asserting its own specifics; the shared unit gets the tests for the shared rule (What a Unit Is).
- A test that has to set flags to make the shared unit behave like one particular caller is evidence the extraction was wrong (Testing as Design Feedback).
- Good extractions get simpler over time as callers converge on the shared shape. Bad ones grow parameters, and the parameter count over time is the most honest available signal (Long Parameter List).
- The right response to a growing parameter list is to split the abstraction back apart, which is unusual and healthy (The Refactoring Loop).
- The heuristic itself ages: in a mature codebase where the domain is well understood, extracting at two is often right, because the shape is known from a dozen prior examples rather than from these two (Design for the Known, Name What You Assumed).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe underlying claim — that you cannot distinguish essential from incidental variation from a single pair of examples — is about inference from evidence, so it holds regardless of language or paradigm.
- DOMAIN-SPECIFICIn a well-understood domain with a mature model — accounting, tax, standard protocol handling — the shape of the variation is known from many prior systems, so extracting at the second case is reasonable. In a novel domain where nobody yet knows which parts vary, even three cases can be too few, and waiting is the more expensive-looking but cheaper choice.
- CONTESTEDThe strongest opposing view is that this heuristic causes real harm at scale: duplication that is tolerated on principle drifts, the drift is discovered only when one copy is fixed and the others are not, and the resulting bugs are silent and expensive — whereas a shared abstraction with an awkward parameter is merely ugly and completely visible. Proponents of extracting early point out that they can always split an abstraction later, while nobody ever goes back and merges four drifted copies they do not know exist. The reply here is that this is right for duplicated *knowledge* and wrong for duplicated *shape*, and that the disagreement usually turns out to be about which of the two is in front of you rather than about the heuristic.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — a shared unit whose tests need flags to simulate each caller is the earliest reliable evidence that an extraction merged two things that were never one.