Rule of three, the wrong abstraction, duplication across bounded contexts and services, test code, and how to justify keeping duplication to reviewers.
Duplication is acceptable when the copies are coincidentally similar but represent different knowledge, so they may change independently. Merging them creates the wrong abstraction: a shared helper that gains a flag for each caller. I also accept duplication across bounded contexts and microservices, where a shared model would couple teams and deployments, and in tests, where readable, self-contained cases beat clever shared setup. In review I justify it with three facts: who owns each copy, whether they change for the same reason, and what coupling a shared version would add.
"Duplication is acceptable, and sometimes better, when the copies are coincidentally similar but represent different knowledge, so they'll change for different reasons, often for different owners. Merging those creates what Sandi Metz calls the wrong abstraction: a shared helper that gains a boolean flag for every caller, until a change for one feature breaks another. 'Duplication is far cheaper than the wrong abstraction.' I also accept duplication across bounded contexts and microservices, where a shared domain model or library would couple teams and deployments; there I share contracts, not code. In tests I'll take readable, self-contained cases (DAMP) over clever shared setup. And early in a feature I follow the rule of three, because two examples rarely show the right abstraction. What I never duplicate is business rules, security checks and contracts that must stay in sync. In review I justify the decision with facts rather than taste: who owns each copy, whether they change for the same reason, what coupling a shared version would add, and what drift would cost. I leave a short comment so nobody 'fixes' it later."
| Context | Why duplication wins |
|---|---|
| Two features that look alike today | Different reasons to change; merging couples them |
| Different owners/teams | Shared code forces cross-team coordination for every change |
Bounded contexts (Sales Customer vs Support Customer) |
Same word, different meaning and rules |
| Microservices | Shared domain libraries force lock-step releases |
| Tests | Each test should read top-to-bottom on its own (DAMP) |
| Early in a feature | Too few examples to see the real abstraction |
| Measured hot paths | A specialised copy can avoid allocations or virtual calls a generic version needs (only with benchmark evidence) |
| Integration adapters per vendor | Each vendor's quirks evolve independently |
| Duplicated | Risk |
|---|---|
| Pricing, tax, eligibility, overdue rules | Wrong charges, inconsistent decisions |
| Authorisation and security checks | One missed copy exposes data |
| Contracts/schemas that must match | Silent drift breaks integrations |
| Constants with business meaning | Inconsistent behaviour after changes |
Fix: inline the shared code back into each caller, delete what each caller doesn't use, then re-extract only what's genuinely common (often a small value object or pure function).
| Factor | Favours merging | Favours duplication |
|---|---|---|
| Reason to change | Same | Different |
| Owner | Same team | Different teams |
| Number of copies | Many (3+) | Two |
| Coupling a shared version adds | Low (same module) | High (across services/teams) |
| Cost of drift | High (money, security, contracts) | Low (formatting, wording) |
| Shared version needs flags? | No | Yes |
"These two validators look identical, but onboarding owns one and billing owns the other, and billing is adding ABN checksum validation next sprint. A shared version would need a
isSupplierflag on day one and would couple two teams' releases. I'd rather keep them separate, leave a comment saying why, and revisit if a third appears. The actual shared knowledge, thePostcodevalue object, is already shared."
The structure: ownership, change reasons, coupling cost, what is shared, when we'll revisit.
We had one AddressFormatter shared by invoicing, shipping labels and marketing emails. Over [two years] it accumulated [11] boolean parameters, and a shipping-label change (upper case for the carrier) shipped to production and broke invoice layouts. When I lined up the callers, they shared almost nothing except the address data itself. Each used a different flag combination. I split it into three context-owned formatters (ShippingLabel, InvoiceAddress, MarketingAddress) and kept the genuinely shared knowledge in an Address value object with validation. Each team now changes its format independently, and we added a review guideline that shared helpers with boolean flags are a smell, plus comments on deliberate duplication so nobody re-merges them.
Checks nuanced judgement and communication in reviews.
Green flags
Red flags
| Idea | Source |
|---|---|
| "Duplication is far cheaper than the wrong abstraction" | Sandi Metz, The Wrong Abstraction (2016) |
| Rule of three | Don Roberts, via Martin Fowler's Refactoring |
| Bounded contexts | Eric Evans, Domain-Driven Design |
| DAMP tests | Common testing guidance (e.g. Google testing blog) |
| AHA ("Avoid Hasty Abstractions") | Kent C. Dodds |
In DDD the same word means different things in different contexts:
| Context | Customer means |
Cares about |
|---|---|---|
| Sales | A lead/account | Pipeline stage, owner, discounts |
| Support | Someone with tickets | SLA tier, open cases |
| Billing | A payer | ABN, payment terms, credit limit |
A single shared Customer class becomes a god object every team edits. Separate models linked by IDs and events (CustomerRegistered) duplicate a few fields but keep each context free to evolve.
| Shared | Effect |
|---|---|
| Domain library (entities, rules) | Coupled releases; a "distributed monolith" |
| DTO classes in a NuGet package | Every contract change requires upgrading all consumers |
| Contract (OpenAPI/protobuf/AsyncAPI) + generated clients | Independent evolution; consumers regenerate when they choose |
Duplicated DTOs per consumer are fine; tolerant readers let producers add fields.
DAMP (Descriptive And Meaningful Phrases) means a test should be understandable in isolation: the reader sees the arrange, act and assert without jumping into base classes. Some repeated arrange lines are a fair price. When setup is genuinely complex, use test data builders (new OrderBuilder().WithLines(3).Paid().Build()), which remove noise without hiding intent. Avoid deep test base classes with hidden setup.
In measured hot paths, a specialised copy (e.g. a Span<byte>-based parser for one format instead of a generic configurable parser) can avoid allocations, virtual dispatch or branching. Only do this with benchmark evidence (BenchmarkDotNet) and a comment linking to it, because it's still a maintenance cost.
// Deliberately separate from SupplierAddressRules: owned by Onboarding, diverges for ABN checks.
// See ADR-0042. Revisit if a third address-rules variant appears.
public static class CustomerAddressRules { ... }
A comment or ADR prevents a well-meaning refactor from re-coupling them.
// The wrong abstraction: one formatter, a flag per caller.
public static class AddressFormatter
{
public static string Format(Address a, bool forLabel, bool includeCountry, bool upperCase)
{
var text = forLabel
? $"{a.Line1}\n{a.Suburb} {a.State} {a.Postcode}"
: $"{a.Line1}, {a.Suburb} {a.State} {a.Postcode}";
if (includeCountry) text += forLabel ? "\nAUSTRALIA" : ", Australia";
return upperCase ? text.ToUpperInvariant() : text;
}
}
// Deliberate duplication: each context owns its format and can change it alone.
public static class ShippingLabel
{
// Australia Post style: upper case, suburb line separate.
public static string Format(Address a) =>
$"{a.Line1}\n{a.Suburb} {a.State} {a.Postcode}".ToUpperInvariant();
}
public static class InvoiceAddress
{
public static string Format(Address a) => $"{a.Line1}, {a.Suburb} {a.State} {a.Postcode}";
}
// Shared knowledge stays shared: the Address value object itself.
public sealed record Address(string Line1, string Suburb, string State, string Postcode);
public static class Demo
{
public static void Main()
{
var a = new Address("1 George St", "Sydney", "NSW", "2000");
Console.WriteLine(ShippingLabel.Format(a));
Console.WriteLine(InvoiceAddress.Format(a));
}
}
What happens: shared code created from two examples. Why it is wrong: two examples rarely reveal the right abstraction. Do instead: wait for a third and identify the true common knowledge.
What happens: one Customer class or domain NuGet package for everyone.
Why it is wrong: every team's change ripples; deployments couple.
Do instead: separate models linked by IDs, contracts and events.
What happens: "it's only a few lines" copied into several services. Why it is wrong: copies drift: wrong charges, exposed data. Do instead: one authoritative place.
What happens: no comment or ADR. Why it is wrong: someone "fixes" it later and reintroduces coupling. Do instead: a short comment stating why the copies are separate.
What happens: "we spent effort building it". Why it is wrong: the wrong abstraction keeps getting worse. Do instead: inline and re-extract.
What happens: "I prefer it this way". Why it is wrong: reviewers can't evaluate it; debates become personal. Do instead: argue with ownership, change reasons, coupling and drift cost.
What happens: deep base classes and shared fixtures hide setup. Why it is wrong: failing tests are hard to understand. Do instead: DAMP tests with builders for complex objects.
What happens: specialised copies "for speed". Why it is wrong: maintenance cost without proven benefit. Do instead: benchmark first; link evidence in a comment.
What happens: copy-paste everywhere in the name of decoupling. Why it is wrong: true duplication of knowledge causes drift bugs. Do instead: separate knowledge from incidental similarity.
What happens: a refactor unifies two teams' code. Why it is wrong: creates coordination cost the owners didn't agree to. Do instead: involve owners; consider whether changes truly align.
Seniors judge duplication by coupling and change patterns, not by how text looks. I am strict about duplicated rules and relaxed about duplicated structure.
| Duplicated thing | My stance |
|---|---|
| Pricing, tax, eligibility rules | Never duplicate |
| Authorisation checks | Never duplicate; centralise in policies |
| DTOs across services | Duplicate; share the contract |
| Formatting for different audiences | Duplicate |
| Test setup | Duplicate for clarity; builders when complex |
| Generic algorithm used three times | Extract |
DRY (from The Pragmatic Programmer) says every piece of knowledge should have one authoritative representation in the system. It is about rules, not text: the GST rate, the definition of an overdue invoice or a validation rule should live in one place. Two blocks of code that look the same but change for different reasons are accidental duplication, and merging them couples unrelated features. I remove true duplication of business knowledge quickly, and I tolerate similar-looking code until a real shared concept appears, usually around the third repetition.
I do not abstract when there is no second variant, no boundary to protect and no testing need, because an abstraction costs indirection, a name, a contract and cognitive load. Typical examples are an IOrderService with one implementation used by one controller, a generic repository over EF Core, or a wrapper around HttpClient or ILogger<T>, which are already abstractions. I do abstract at architecture boundaries (ports for payment vendors, email, storage) and where tests need a seam for something slow or non-deterministic. If an abstraction has had one implementation and one caller for a long time and is not at a boundary, I inline it.
Coupling is the degree to which one module depends on another: how much it must know about it, and how likely it is to change when the other changes. Some coupling is unavoidable; the goal is coupling that is explicit, narrow and stable. In code the harmful kinds are reaching into another type's internals, shared mutable global state and passing control flags. In distributed .NET systems the expensive kinds are temporal coupling (synchronous call chains), shared databases and lock-step deployments. I reduce them with narrow interfaces, published contracts, asynchronous messaging where it fits, and each service owning its data.
How to tell it (STAR)
We had one address formatter with [11] flags, and a shipping change broke invoices. When I lined up the callers, they shared almost nothing except the address itself. I split it into three formatters owned by each team and kept a shared address type, and since then each team changes its format without breaking the others.
Last updated 31 Oct 2026.