Don't Repeat Yourself as single source of knowledge, not identical text; accidental versus true duplication; the wrong abstraction; where DRY matters most.
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.
"DRY comes from The Pragmatic Programmer: 'every piece of knowledge must have a single, unambiguous, authoritative representation within a system'. The key word is knowledge, not text. The GST rate, what counts as an overdue invoice, a validation rule, a database schema, an API contract: each should live in one place. When it's copied, the copies drift, and you get bugs like reminder emails sent for disputed invoices because only three of four copies were updated. But two blocks of code that merely look alike and change for different reasons are accidental duplication. Merging them couples unrelated features, and the shared helper slowly grows
isSupplier/skipValidationflags until it's what Sandi Metz calls the wrong abstraction, which costs more than the duplication did. So I remove true duplication of business knowledge quickly, I tolerate similar-looking incidental code until a real shared concept appears (often around the third occurrence), and across service boundaries I share contracts, not domain code."
| DRY targets | Single source |
|---|---|
| Business rules (overdue, eligibility, pricing) | One named policy/domain method |
| Constants with meaning (tax rate, grace days) | Configuration or named constants |
| Schemas | EF Core model → migrations; or DB-first scaffolding, not both by hand |
| API contracts | OpenAPI → generated clients; protobuf → generated code |
| Validation shared by layers | One validator (or generated from the contract) |
| Status/state values | Enums or constants, not scattered strings |
| Documentation that restates code | Generate it, or keep it at the level of why |
| True duplication | Accidental duplication | |
|---|---|---|
| Encodes | The same knowledge | Different knowledge that happens to look alike |
| Changes | Together, for the same reason | Independently, for different reasons |
| Owned by | Same actor | Different actors/features |
| If merged | One fix applies everywhere ✅ | Shared code grows flags and conditionals ❌ |
| Example | Overdue rule in 4 places | Customer vs supplier address validation |
| Situation | Why merging hurts |
|---|---|
| Similar code serving different features/teams | Couples change cycles; one team's change breaks the other |
| Shared helper needs a flag per caller | The wrong abstraction: harder to change than the copies |
| Domain code shared across microservices | Couples deployments (distributed monolith) |
| Tests made DRY with deep base classes/helpers | Each test becomes unreadable on its own (prefer DAMP) |
| Premature extraction after two occurrences | You guess the shape of the abstraction before you know it |
| Shared "Common" project for unrelated utilities | Becomes a dependency magnet everyone must upgrade |
| Question | If yes |
|---|---|
| Must all copies change together? | Merge |
| Would a change request mention only one of them? | Keep separate |
| Would the shared version need per-caller flags? | Keep separate (or split) |
| Is this a business rule? | Give it one named home now |
| Third occurrence of the same concept? | Extract with confidence |
In our [invoicing platform], the overdue rule (due date + [14] days, excluding disputed and paid invoices) was coded inline in [four] places: the dashboard, the reminder job, a report and the public API. A policy change to exclude disputed invoices updated three of them, and the reminder job started emailing customers about disputed invoices, which generated complaints. Searching for the grace-day constant found all four copies. I created InvoicePolicy.IsOverdue(invoice, today) with focused unit tests and replaced every copy. The next policy change was a one-line edit in one place, and we added a review checklist item: business rules live in named policies, not inline conditions.
Checks whether the candidate understands DRY as a principle about knowledge rather than a mechanical rule.
Green flags
Red flags
Andy Hunt and Dave Thomas defined DRY in The Pragmatic Programmer (1999) and explicitly applied it beyond code: database schemas, build scripts, tests and documentation. They later stressed that DRY is about knowledge and intent, not textual sameness.
| Type | Example | Remedy |
|---|---|---|
| Imposed | Schema in SQL and in C# by hand | Generate one from the other (EF migrations, scaffolding, source generators) |
| Inadvertent | Two developers implement the same rule unknowingly | Discoverable policies, code search, shared vocabulary |
| Impatient | Copy-paste to save time | Extract when it's the same knowledge |
| Inter-developer | Teams duplicate utilities | Ownership and internal packages for genuinely shared, stable code |
Sandi Metz's sequence:
The fix is counter-intuitive: inline the shared code back into each caller, delete what each caller doesn't use, and then re-extract only what's genuinely common. "Duplication is far cheaper than the wrong abstraction."
First time, write it. Second time, notice the duplication (maybe accept it). Third time, you now see the real shape of the shared concept and can extract it with the right parameters. This balances DRY against premature abstraction.
| Shared | Effect |
|---|---|
| Domain library (entities, rules) across services | Coupled releases; changes need coordinated deploys |
| Contracts (OpenAPI, protobuf, AsyncAPI) | Services stay independent; clients generated from the contract |
| Small, stable technical packages (logging setup, auth helpers) | Reasonable, versioned like any library |
Within a service, DRY strongly; between services, prefer duplication of DTOs over shared domain code.
Tests are read far more than they're changed together. "Descriptive And Meaningful Phrases" means each test should be understandable on its own. Use test data builders for genuinely repeated setup, but avoid deep base classes and helper chains that hide what a test does.
0.1m, 14, "Overdue") and list each use.var invoices = new[]
{
new Invoice(1, new DateOnly(2026, 9, 1), Disputed: false, Paid: false),
new Invoice(2, new DateOnly(2026, 9, 1), Disputed: true, Paid: false),
new Invoice(3, new DateOnly(2026, 10, 20), Disputed: false, Paid: false)
};
var today = new DateOnly(2026, 10, 23);
// Dashboard, reminder job and report all ask the same policy.
foreach (var inv in invoices.Where(i => InvoicePolicy.IsOverdue(i, today)))
Console.WriteLine($"Invoice {inv.Id} is overdue");
public sealed record Invoice(int Id, DateOnly DueDate, bool Disputed, bool Paid);
// One authoritative representation of the 'overdue' rule.
public static class InvoicePolicy
{
public const int GraceDays = 14;
public static bool IsOverdue(Invoice invoice, DateOnly today) =>
!invoice.Paid &&
!invoice.Disputed &&
today > invoice.DueDate.AddDays(GraceDays);
}
Accidental duplication that should stay separate:
// Similar today, but customer and supplier rules are owned by different teams
// and will diverge (suppliers need an ABN check, customers do not).
public static class CustomerAddressRules
{
public static bool IsValid(string postcode) => postcode.Length == 4 && postcode.All(char.IsDigit);
}
public static class SupplierAddressRules
{
public static bool IsValid(string postcode) => postcode.Length == 4 && postcode.All(char.IsDigit);
}
What happens: any two similar blocks are merged. Why it is wrong: similar text isn't the same knowledge; merging couples independent features. Do instead: ask whether the copies must always change together.
What happens: Validate(address, isSupplier: true, skipPostcode: false).
Why it is wrong: the wrong abstraction: harder to change than the original copies.
Do instead: inline back into callers, then re-extract only what's truly common.
What happens: a Company.Domain package used by every service.
Why it is wrong: coupled releases; a distributed monolith.
Do instead: share contracts; accept duplicated DTOs.
What happens: "it's just one line, I'll copy it". Why it is wrong: short rules drift silently and cause the worst bugs. Do instead: give every business rule one named home.
What happens: abstractions designed from two examples. Why it is wrong: the shape is a guess and often wrong. Do instead: wait for the third, unless it's clearly the same business rule.
What happens: tests built on deep base classes and shared setup helpers. Why it is wrong: individual tests become unreadable; failures are hard to diagnose. Do instead: DAMP tests with builders for genuinely repeated setup.
What happens: SQL scripts and EF model both edited manually. Why it is wrong: they drift; production schema doesn't match the model. Do instead: one source of truth (migrations or scaffolding).
What happens: "Overdue", "overdue", "OVERDUE" across code.
Why it is wrong: typos and inconsistent comparisons.
Do instead: enums or constants.
Common projectWhat happens: every utility goes into one shared project. Why it is wrong: low cohesion; every project depends on everything. Do instead: put shared code with the concept it belongs to; keep shared packages small and focused.
What happens: the same rule explained in wiki, README and comments. Why it is wrong: the explanations drift from the code. Do instead: document why once; let tests and code document what.
Seniors apply DRY to knowledge and tolerate duplication of incidental code. The cost of duplication grows with the number of copies and the importance of the rule; the cost of a premature abstraction grows with every caller.
| Situation | Choice |
|---|---|
| Business rule in several places | Merge now |
| Two similar handlers for different features | Leave, revisit at the third |
| DTOs across service boundaries | Duplicate; share the contract |
| Test arrange code | Builders for real repetition; prefer readable tests |
| Shared helper with many flags | Inline and re-extract |
YAGNI (You Aren't Gonna Need It, from Extreme Programming) says do not build a capability until you actually need it. Speculative features cost time to build, delay what is needed now, add code to maintain, and are often wrong when the real requirement arrives. YAGNI does not mean skipping tests or refactoring; it is those practices that make adding the feature later cheap. I make an exception for decisions that are expensive to reverse, such as public API contracts, persisted data formats and security, where some upfront design pays off.
KISS (Keep It Simple) means choosing the simplest design that meets the actual requirements, so the code is easy to read, test and change. The useful distinction is between essential complexity (the problem really is hard, such as tax rules) and accidental complexity (layers, patterns and frameworks we added). I also separate simple (few interleaved concepts) from easy (familiar or quick to write): copying a framework template is easy but may not be simple. In practice it means a plain class before an abstraction, a direct EF Core query before a generic repository, and readable code over clever LINQ.
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)
After a policy change, our reminder job started chasing customers with disputed invoices. I found the overdue rule written in [four] places and only three had been updated. I moved it into one policy class with tests, and every consumer now calls it, so the next rule change was a single edit.
Last updated 31 Oct 2026.