DRY, KISS and YAGNI: Duplication, Simplicity and "You Aren't Gonna Need It"
DRY means a single source for knowledge, not for code; merging two rules that change for different reasons just because they look alike is not DRY but a wrong abstraction. Backing out of a wrong abstraction as Metz suggests, the rule of three, measuring KISS with CA1502, and the four costs Fowler lists for YAGNI.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
The Bookstore has two discounts. Marketing's member discount: 10 percent for members on carts of 200 EUR and above. The contract discount the sales team signed with corporate customers: 10 percent on orders of 200 EUR and above, capped at 500 EUR. Two years ago a developer noticed that the two rules looked the same and merged them into one method. This week marketing wanted to drop the threshold to 150 for Black Friday; the change was one line and the pull request was approved in five minutes:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine($"Member, 180: {DiscountCalculator.CalculateDiscount(180m, CustomerType.Member):0.00}");
Console.WriteLine($"Corporate, 180: {DiscountCalculator.CalculateDiscount(180m, CustomerType.Corporate):0.00}");
Console.WriteLine($"Corporate, 6000: {DiscountCalculator.CalculateDiscount(6000m, CustomerType.Corporate):0.00}");
public enum CustomerType { Regular, Member, Corporate }
public static class DiscountCalculator
{
private const decimal Threshold = 150m; // marketing's request: 200 → 150
private const decimal Rate = 0.10m;
private const decimal CorporateCap = 500m;
// Member discount and corporate contract discount were merged as "the same code"
public static decimal CalculateDiscount(decimal subtotal, CustomerType type)
{
if (type == CustomerType.Regular || subtotal < Threshold)
return 0m;
decimal discount = subtotal * Rate;
return type == CustomerType.Corporate ? Math.Min(discount, CorporateCap) : discount;
}
}
Marketing only wanted to change the member discount. How much discount do you think a 180 EUR corporate order gets now? The number in the contract is 200.
Two Rules, One Method
The output:
Member, 180: 18.00
Corporate, 180: 18.00
Corporate, 6000: 500.00
The corporate customer received an 18 EUR discount that is not in their contract. Nobody asked for it; the sales team does not even know. The bug will be noticed after invoices go out, probably when an account manager asks "where did this discount come from?". The tests passed too, because the new test marketing wrote only checked members.
What is wrong here is not the number 150 but the decision that tied two separate business rules to one piece of code. The two rules were born on the same day with the same numbers, so their code was identical. But they change for different reasons, at the request of different people: one follows the campaign calendar, the other follows signed contracts. In the language of this series' SRP (Single Responsibility Principle) post, the rules of two different actors live in one place. The developer who thought they were applying DRY (Don't Repeat Yourself) took two pieces of knowledge for one.
What DRY Really Means: A Single Source of Knowledge
DRY comes from Andy Hunt and Dave Thomas's 1999 book The Pragmatic Programmer, and the word "code" does not appear in its definition: every piece of knowledge in a system should have a single, unambiguous, authoritative representation. In the 20th anniversary edition in 2019 the authors expanded the DRY section for exactly this reason, stressing that the principle is often read as "don't copy code" while its real subject is the duplication of knowledge and intent.
That distinction cuts both ways. First, two similar pieces of code are not always a DRY violation; like the two discounts above, they may be two separate pieces of knowledge that happen to look the same. The "Architectural principles" page on Microsoft Learn makes the same note when describing DRY: two constants with the same value should not be collapsed into one if they conceptually refer to different things; duplication is always preferable to coupling to the wrong abstraction. Second, DRY can be violated even when no code repeats. If the VAT (value-added tax) rate is written in a C# constant, in a database CHECK constraint and in an Angular pipe, there are three pieces of code in three languages, but the knowledge lives in three places.
A good .NET example is the web rules for JSON (JavaScript Object Notation). A web API (Application Programming Interface) writes property names in camelCase, ignores casing in incoming JSON and accepts numbers that arrive as strings. That knowledge lives in one place, JsonSerializerDefaults.Web, and since .NET 9 it comes ready-made as the JsonSerializerOptions.Web singleton:
using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Book dune = new("978-0441013593", "Dune", 12.99m);
// Default: property names as they are
Console.WriteLine(JsonSerializer.Serialize(dune));
// Web defaults: defined in one place
Console.WriteLine(JsonSerializer.Serialize(dune, JsonSerializerOptions.Web));
Console.WriteLine(JsonSerializer.Deserialize<Book>("""{"ISBN":"978-0141439518","TITLE":"Emma","price":"7.50"}""", JsonSerializerOptions.Web));
// The HttpClient side doesn't restate the knowledge; it uses the same defaults
Console.WriteLine(await JsonContent.Create(dune).ReadAsStringAsync());
public sealed record Book(string Isbn, string Title, decimal Price);
{"Isbn":"978-0441013593","Title":"Dune","Price":12.99}
{"isbn":"978-0441013593","title":"Dune","price":12.99}
Book { Isbn = 978-0141439518, Title = Emma, Price = 7.50 }
{"isbn":"978-0441013593","title":"Dune","price":12.99}
The last line matters: we passed no options to JsonContent.Create from System.Net.Http.Json, and it still produced camelCase. According to the documentation, ASP.NET Core's defaults are the same web defaults. So the server and the client do not each define "how JSON is written on the web"; they use the same source. If one changes, the other follows.
Backing Out of a Wrong Abstraction
Sandi Metz (author of Practical Object-Oriented Design in Ruby) summed up this situation in one sentence in her 2014 RailsConf (the Ruby on Rails conference) talk and expanded on it in her 2016 post "The Wrong Abstraction": duplication is far cheaper than the wrong abstraction. The story she tells reads like the two-year history of the opening CalculateDiscount. Someone sees duplication and extracts a shared method. A new requirement arrives that almost fits the abstraction; a parameter and an if are added. The next requirement adds another parameter. After a while nobody fully understands the method, but so much effort has gone into it that nobody dares to delete it either.
Metz's advice is counterintuitive: the fastest way forward is back. Inline the abstraction into every caller, keep in each caller only the branch it actually uses, then look again at the real duplication that remains. In the discount example, this means giving the two rules back their own classes and their own constants:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine($"Member, 180: {Discounts.For(CustomerType.Member, 180m):0.00}");
Console.WriteLine($"Corporate, 180: {Discounts.For(CustomerType.Corporate, 180m):0.00}");
Console.WriteLine($"Corporate, 6000: {Discounts.For(CustomerType.Corporate, 6000m):0.00}");
public enum CustomerType { Regular, Member, Corporate }
// Owner: marketing. Changes often, with campaigns.
public static class MemberDiscount
{
private const decimal Threshold = 150m;
private const decimal Rate = 0.10m;
public static decimal For(decimal subtotal) => subtotal >= Threshold ? subtotal * Rate : 0m;
}
// Owner: sales. Changes with the contract.
public static class CorporateContractDiscount
{
private const decimal Threshold = 200m;
private const decimal Rate = 0.10m;
private const decimal Cap = 500m;
public static decimal For(decimal subtotal) =>
subtotal >= Threshold ? Math.Min(subtotal * Rate, Cap) : 0m;
}
public static class Discounts
{
public static decimal For(CustomerType type, decimal subtotal) => type switch
{
CustomerType.Member => MemberDiscount.For(subtotal),
CustomerType.Corporate => CorporateContractDiscount.For(subtotal),
_ => 0m
};
}
Member, 180: 18.00
Corporate, 180: 0.00
Corporate, 6000: 500.00
Both classes now contain Rate = 0.10m, and that duplication is deliberate. They use the same rate today, but one will change with a campaign decision and the other with a contract negotiation. When marketing lowers the threshold to 120, the sales team's code does not change; when the corporate contract moves to 12 percent in 2027, members are not affected. The comment at the top of each class reminds whoever later says "these are the same, let's merge them" why they are kept apart.
The Rule of Three, WET and AHA
So when should you merge? The oldest answer is the rule of three. Martin Fowler attributes it to Don Roberts (one of the developers of the Refactoring Browser, the first automated refactoring tool) in his 1999 book Refactoring: the first time you do something, you just do it; the second time you notice the duplication and do it anyway; the third time you refactor. The reasoning is that generalizing from two examples is a guess, while three examples give you enough data to separate what is shared from what varies.
The same idea has two newer names. WET (Write Everything Twice) allows you to write something twice and stop at the third. AHA (Avoid Hasty Abstractions) warns against abstracting in a hurry; in his 2020 post Kent C. Dodds (an educator known for JavaScript and testing) credits Cher Scarlett with the acronym and adds "optimize for change first" to Metz's warning. In the same post Dodds finds WET too rigid as well; he wants you to look at meaning, not at a count.
All of these rules can be reduced to one question: do these two pieces change for the same reason, at the request of the same person? If yes, there is one piece of knowledge and merging it is DRY. If no, there are two pieces of knowledge and merging them is coupling. Waiting until the count reaches three usually buys you time to learn the answer to that question.
KISS: Making Simplicity Measurable
KISS (Keep It Simple, Stupid) is usually attributed to Kelly Johnson, the engineer who led Lockheed's Skunk Works: an aircraft had to be simple enough to be repaired in the field with ordinary tools. In software, "simple" is open to debate. The distinction in Rich Hickey's (creator of the Clojure language) 2011 talk "Simple Made Easy" helps: simple means not intertwined; easy means familiar, close at hand. Adding a framework is easy, but it may not make the system simpler.
One way to make the debate concrete is to measure. CA1502 in the .NET analyzers warns when a method's cyclomatic complexity (the number of independent paths) exceeds a threshold. The rule is off by default and its threshold is 25; let's set it to 10 for the Bookstore. We add a CodeMetricsConfig.txt file to the project, mark it as AdditionalFiles, and enable the rule in .editorconfig:
# CodeMetricsConfig.txt
CA1502: 10
# .editorconfig
[*.cs]
dotnet_diagnostic.CA1502.severity = warning
namespace Bookstore.Shipping;
public static class ShippingCalculator
{
public static decimal Cost(string country, decimal weightKg, bool express, bool isMember, decimal subtotal)
{
decimal cost;
if (country == "TR")
{
if (weightKg <= 1m) cost = 40m;
else if (weightKg <= 5m) cost = 60m;
else cost = 90m;
}
else if (country == "DE" || country == "NL" || country == "FR")
{
if (weightKg <= 1m) cost = 120m;
else if (weightKg <= 5m) cost = 180m;
else cost = 260m;
}
else
{
if (weightKg <= 1m) cost = 200m;
else cost = 350m;
}
if (express) cost *= 2m;
if (isMember && country == "TR" && subtotal >= 500m && !express) cost = 0m;
return cost;
}
}
Shipping.cs(5,27): warning CA1502: 'Cost' has a cyclomatic complexity of '15'. Rewrite or refactor the code to decrease its complexity below '11'.
Fifteen different paths mean fifteen different test scenarios. Writing the same rules with a table and two small methods:
namespace Bookstore.Shipping;
public static class ShippingCalculator
{
private static readonly HashSet<string> EuZone = ["DE", "NL", "FR"];
// Weight tiers per zone: (max kg, price)
private static readonly Dictionary<string, (decimal MaxKg, decimal Price)[]> Rates = new()
{
["TR"] = [(1m, 40m), (5m, 60m), (decimal.MaxValue, 90m)],
["EU"] = [(1m, 120m), (5m, 180m), (decimal.MaxValue, 260m)],
["World"] = [(1m, 200m), (decimal.MaxValue, 350m)],
};
public static decimal Cost(string country, decimal weightKg, bool express, bool isMember, decimal subtotal)
{
if (IsFreeForMember(country, express, isMember, subtotal))
return 0m;
decimal baseCost = Rates[ZoneOf(country)].First(r => weightKg <= r.MaxKg).Price;
return express ? baseCost * 2m : baseCost;
}
private static string ZoneOf(string country) =>
country == "TR" ? "TR" : EuZone.Contains(country) ? "EU" : "World";
private static bool IsFreeForMember(string country, bool express, bool isMember, decimal subtotal) =>
isMember && country == "TR" && subtotal >= 500m && !express;
}
In this version CA1502 raises no warning, and the two sample calls still return 0 and 360. The complexity did not disappear; it moved: the tariff is now a data table that looks just like the price list the carrier sends. Adding a new weight tier is one line, and moving a country into the EU (European Union) zone is one string. The member rule stands apart under its own name.
Use the metric as an alarm, not a verdict. Microsoft's documentation also says that a large switch can be easy to understand and that the warning can be suppressed. A switch over the seven days of the week has a complexity of 8 but takes a second to read. The warning says "take another look here", not "split this".
YAGNI and the Four Costs
YAGNI (You Aren't Gonna Need It) comes from Extreme Programming. According to Fowler's 2015 bliki (the blog-and-wiki mix of short articles on his site) entry, the phrase grew out of conversations between Kent Beck (who introduced Extreme Programming) and Chet Hendrickson on the C3 (Chrysler Comprehensive Compensation, Chrysler's payroll system and the first Extreme Programming project) project: whenever Chet proposed capabilities the system would soon need, Kent replied that they weren't going to need them. In the same entry Fowler separates four costs of a feature built today "because we'll need it later". Imagine multi-currency support added to the Bookstore while it sells nothing outside Turkey:
| Cost | Fowler's definition | Multi-currency in the Bookstore |
|---|---|---|
| Build | Analyzing, writing and testing the feature | Two sprints for Money, an exchange rate service, a rate table and their tests |
| Delay | The lost value of the work not done instead | The returns flow that was not built in those two sprints |
| Carry | The extra complexity slowing every change | A currency parameter in every price calculation, a fake rate in every test |
| Repair | Fixing it once it turns out to be wrong | Discovering at the first real foreign sale that the rate must be taken at invoice time, not order time |
Two points Fowler stresses are essential for applying YAGNI correctly. First, even if the feature turns out to be needed, the build and carry costs have already been paid; the only gain from building early is the delay cost. Second, YAGNI does not apply to effort spent keeping the code easy to change: refactoring, automated tests, continuous delivery. Those are YAGNI's prerequisites; to say "we don't need it yet" you must be confident you can make the change cheaply when the need arrives.
.NET's own design rules sit in the same place. The Framework Design Guidelines (Krzysztof Cwalina and Brad Abrams's book of .NET library design rules) advise against making a member virtual unless you have a good reason and understand the costs of designing, testing and maintaining virtual members, and recommend limiting extensibility to what is truly necessary. Even for a framework, every open door is a promise that needs maintenance.
Common Pitfalls
Merging based on line similarity. Before moving two identical method bodies into a shared helper, ask "who changes these, and why?". Merging the rules of different actors means one team's change leaks into another team's invoices, as in the opening example.
Keeping a wrong abstraction alive with parameters. When flags like bool isCampaign, bool applyCap and bool skipThreshold start piling up, the method represents more than one thing. Instead of adding another flag, consider inlining and splitting it.
Thinking DRY is only about code. Writing the same validation rule separately in a C# attribute, a SQL (Structured Query Language) constraint and the frontend does not look like code duplication, but it is a DRY violation. If a single source is not possible, at least generate one from the other or have a test compare them.
Mistaking KISS for "fewest lines". Nested ternaries squeezed into one line are short but not simple. Simplicity is measured by how little the reader has to hold in their head, not by character count.
Using YAGNI as an excuse for low quality. "We don't need tests yet" or "let's not split this method yet" is not YAGNI; those are exactly the things Fowler explicitly excludes. YAGNI applies to features, not to the health of the code.
Playing the principles against each other. DRY says "merge", YAGNI says "don't add it yet", KISS says "keep it simple", and all three can clash in the same code. Their common denominator is the cost of change: whichever makes the next change cheaper wins.
A Small Experiment
★☆☆ The sales team raises the corporate contract threshold to 250. Try making this change in the opening DiscountCalculator and think about how you could do it without breaking the member discount. Make the same change in the split version and compare the two experiences.
★★☆ Enable CA1502 with a threshold of 10 in your own code base. Find the three methods with the highest complexity. For each one, answer: "does this number really make it harder to read, or is it a plain switch?"
★★★ Think about where the Bookstore's VAT rate is written: a C# constant, the database, the invoice template, the frontend. Propose a design that feeds this knowledge from a single source, and count how many files change when a new rate arrives.
In the next post we return to inheritance. The class-level counterpart of this post's wrong abstraction is a wrong base class: the fragile base class problem, plugging behavior in and out with composition and delegation, and why behaviors that grow along two axes lead to a subclass explosion.