Mert Özen Further With Every Line
Software Design Clean Code — #11

The Four Rules of Simple Design: Passes Tests, Reveals Intent, No Duplication, Fewest Elements

Mert Özen Nov 7, 2026 13 min 2 views
The Four Rules of Simple Design: Passes Tests, Reveals Intent, No Duplication, Fewest Elements

Kent Beck's simple design rules, in priority order, are: passes the tests, reveals intention, no duplication, fewest elements. The post grows a coupon discount calculation step by step with these four questions, separates coincidental similarity from real duplication and shows the cost of an over-generalized version that passes the same tests.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First: Which One Is Simpler?

Bookstore's cart page is getting coupons and a VIP (Very Important Person, the loyal-customer segment) discount. The team has two implementations, and both pass the same seven tests. The first is a single class:

public sealed class DiscountCalculator
{
    private const decimal WelcomeCouponRate = 0.10m;
    private const decimal SummerCouponAmount = 25m;
    private const decimal VipRate = 0.05m;

    public decimal Calculate(decimal subtotal, string? couponCode, CustomerTier tier)
    {
        decimal discount = CouponDiscount(subtotal, couponCode);
        if (tier == CustomerTier.Vip) discount += PercentOf(subtotal, VipRate);
        return Math.Min(discount, subtotal);
    }

    private static decimal CouponDiscount(decimal subtotal, string? couponCode) => couponCode switch
    {
        null => 0m,
        "WELCOME10" => PercentOf(subtotal, WelcomeCouponRate),
        "SUMMER25" => SummerCouponAmount,
        _ => throw new ArgumentException($"Unknown coupon '{couponCode}'.", nameof(couponCode)),
    };

    // Discounts are rounded to the kuruş; 0.005 rounds up
    private static decimal PercentOf(decimal amount, decimal rate) =>
        Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero);
}

The second consists of an IDiscountRule interface, a DiscountContext record, an abstract PercentageDiscountRule, four rule classes, a DiscountRuleFactory and a DiscountCalculator that sums them up: nine types, sixty lines. In the pull request someone defends the second one as "extensible, adding a rule is just adding a class", and someone else defends the first as "you read it and you're done".

Answer in your head: which one is simpler, and what are you measuring when you say "simple"? Lines, types, or the cost of adding the next rule? And one more: the 0.10m in the first version is the same number as the 10 percent tax rate elsewhere on the site. Does merging both into a single TenPercent constant reduce duplication, or does it do something else?

This post answers these questions with Kent Beck's four rules and comes back to both implementations at the end.

Where the Four Rules Come From

The rules come from XP (Extreme Programming, the software development approach Kent Beck described in the late 1990s). In the first edition of Extreme Programming Explained in 1999, Beck defined the "simple design" practice in four items: the system runs all the tests, contains no duplicated logic, states every intention important to the programmers and has the fewest possible classes and methods. In 2015 Martin Fowler's bliki post Beck Design Rules gave them the short form most people quote today: passes the tests, reveals intention, no duplication, fewest elements. Beck reviewed that post.

The rules will also be familiar to readers of Clean Code (Robert C. Martin's 2008 book). In the first edition, the Emergence chapter written by Jeff Langr is built entirely on these four rules. In the second edition published in 2025, the topic got its own chapter called Simple Design; according to the publisher's table of contents its sections are YAGNI (You Aren't Gonna Need It), Covered by Tests, Maximize Expression and Minimize Duplication. So Martin renames the first three rules as "covered by tests, maximizes expression, minimizes duplication"; the table of contents has no separate heading for the fourth rule and a YAGNI heading in its place.

The shared claim of the rules is this: when you discuss design quality, you can ask four questions instead of arguing taste. According to Fowler's note, Beck also says design quality is not purely subjective and that these rules are generally good at predicting which designs will look good in hindsight.

Why the Order Matters

The rules are written in priority order. Code that fails its tests is not simple no matter how readable it is, because it is wrong. Once the tests pass, telling the reader your intent comes before removing duplication. Unnecessary elements are cleaned up last. The fourth rule is deliberately the weakest: you don't delete a class that the first three rules need just to keep the element count down.

The order of the second and third rules is debated. Fowler writes that their relative order hardly matters because they feed each other; keeping duplication for the sake of clarity often papers over a problem that should be solved. The rare exception Beck told Fowler about is test code, where empathy for the reader can win over a technical metric. In his post The Four Elements of Simple Design, J.B. Rainsberger flips the order: he puts removing duplication slightly ahead of clarity, then immediately adds that the order doesn't really matter. His practical summary is useful: "remove duplication" and "improve names" feed each other in small cycles. As you remove duplication, structure emerges; as you fix names, responsibilities slide to the right place. As for the fourth rule, he says he has never needed to invoke it, because a design that does the first two well already ends up with a reasonable number of elements.

In practice, this reading of the order works for me: the first rule is not negotiable, the last one is the easiest to sacrifice, and the two in the middle work together.

Emergent Design: Design Follows the Tests

What's interesting about these rules is that they don't require you to draw a design up front. The idea: move in small steps, make the tests pass at each step, then improve the code according to the four rules. The design emerges from this loop, which is why the first edition of Clean Code calls the chapter Emergence. It is not the same thing as the refactor step of the TDD (Test-Driven Development) cycle, but it's very close: TDD tells you to write the test first, while the simple design rules tell you which direction to move the code in while the tests are green.

There is a prerequisite: tests. The second, third and fourth rules all require changing code, and changing code without tests is not design but gambling. So the first rule doesn't only say "the code must work"; it also says "you must have a safety net that lets you dare to change it". We covered the rules for keeping tests clean in the previous post, and the technical side of xUnit in the C# Advanced series post "Unit Testing in C#: xUnit, Theory/InlineData and Testable Code".

In Practice: Growing a Discount Calculation with the Four Rules

Let's see step by step how we arrived at the single-class version from the opening. The first request is a single coupon: WELCOME10 takes 10 percent off the cart total.

Step 1: Passes the tests

For the first rule, first a test, then the simplest code that passes it:

public class DiscountCalculatorTests
{
    private readonly DiscountCalculator calculator = new();

    [Fact]
    public void Welcome_coupon_takes_ten_percent_off()
    {
        Assert.Equal(20m, calculator.Calculate(200m, "WELCOME10"));
    }
}
public sealed class DiscountCalculator
{
    public decimal Calculate(decimal subtotal, string? couponCode) => subtotal * 0.1m;
}

This code doesn't check the coupon at all, on purpose. The single example I have doesn't require it; I don't generalize before a second example arrives. When the second request comes in, I add tests: SUMMER25 takes a fixed 25 TL off, and a cart without a coupon gets no discount. Running them against the current code:

  Failed Bookstore.Discounts.Tests.DiscountCalculatorTests.Summer_coupon_takes_a_fixed_amount_off [2 ms]
Expected: 25
Actual:   20.0
  Failed Bookstore.Discounts.Tests.DiscountCalculatorTests.No_coupon_means_no_discount [< 1 ms]
Expected: 0
Actual:   20.0
Failed!  - Failed:     2, Passed:     1, Skipped:     0, Total:     3, Duration: 10 ms - Bookstore.Discounts.Tests.dll (net10.0)

The red tests tell me exactly what I need to generalize. The simplest fix:

public sealed class DiscountCalculator
{
    public decimal Calculate(decimal subtotal, string? couponCode)
    {
        if (couponCode == "WELCOME10") return subtotal * 0.1m;
        if (couponCode == "SUMMER25") return 25m;
        return 0m;
    }
}

Step 2: Is the intent clear?

Three tests are green. Now I ask the second rule: would someone reading this code for the first time understand what 0.1m is? At this size they would, WELCOME10 is right next to it. So I don't change anything yet; the intent is clear. A new request arrives: VIP customers get an extra 5 percent, percentage discounts are rounded to the kuruş, and the total discount may not exceed the cart amount. Unknown coupons should not be silently ignored either. The tests:

public class DiscountCalculatorTests
{
    private readonly DiscountCalculator calculator = new();

    [Theory]
    [InlineData(200, "WELCOME10", 20)]
    [InlineData(200, "SUMMER25", 25)]
    [InlineData(200, null, 0)]
    public void Coupon_discounts_for_a_normal_customer(decimal subtotal, string? coupon, decimal expected)
    {
        Assert.Equal(expected, calculator.Calculate(subtotal, coupon, CustomerTier.Normal));
    }

    [Fact]
    public void Discount_never_exceeds_the_subtotal()
    {
        Assert.Equal(20m, calculator.Calculate(20m, "SUMMER25", CustomerTier.Normal));
    }

    [Fact]
    public void Vip_discount_stacks_with_the_coupon()
    {
        Assert.Equal(30m, calculator.Calculate(200m, "WELCOME10", CustomerTier.Vip));
    }

    [Fact]
    public void Percentage_discounts_are_rounded_to_the_kurus()
    {
        Assert.Equal(5.00m, calculator.Calculate(99.99m, null, CustomerTier.Vip));
    }

    [Fact]
    public void Unknown_coupon_is_rejected()
    {
        Assert.Throws<ArgumentException>(() => calculator.Calculate(200m, "FREE", CustomerTier.Normal));
    }
}

The first version that passes them:

public sealed class DiscountCalculator
{
    public decimal Calculate(decimal subtotal, string? couponCode, CustomerTier tier)
    {
        decimal discount = 0m;
        if (couponCode == "WELCOME10") discount += Math.Round(subtotal * 0.10m, 2, MidpointRounding.AwayFromZero);
        else if (couponCode == "SUMMER25") discount += 25m;
        else if (couponCode != null) throw new ArgumentException($"Unknown coupon '{couponCode}'.", nameof(couponCode));
        if (tier == CustomerTier.Vip) discount += Math.Round(subtotal * 0.05m, 2, MidpointRounding.AwayFromZero);
        if (discount > subtotal) discount = subtotal;
        return discount;
    }
}

Step 3: No duplication?

The tests pass and the third rule kicks in. Math.Round(... , 2, MidpointRounding.AwayFromZero) is written twice. These aren't just two similar lines, they are the same knowledge: "percentage discounts are rounded to the kuruş, half a kuruş rounds up". If accounting changes the rounding rule, I have to change two places, and if I forget one, the tests may or may not catch it. I move the rule to one place. At the same time I go back to the second rule: 0.10m and 0.05m now get lost among three different rates, so I name them; the if/else if chain becomes a switch expression so it reads like a coupon table:

public sealed class DiscountCalculator
{
    private const decimal WelcomeCouponRate = 0.10m;
    private const decimal SummerCouponAmount = 25m;
    private const decimal VipRate = 0.05m;

    public decimal Calculate(decimal subtotal, string? couponCode, CustomerTier tier)
    {
        decimal discount = CouponDiscount(subtotal, couponCode);
        if (tier == CustomerTier.Vip) discount += PercentOf(subtotal, VipRate);
        return Math.Min(discount, subtotal);
    }

    private static decimal CouponDiscount(decimal subtotal, string? couponCode) => couponCode switch
    {
        null => 0m,
        "WELCOME10" => PercentOf(subtotal, WelcomeCouponRate),
        "SUMMER25" => SummerCouponAmount,
        _ => throw new ArgumentException($"Unknown coupon '{couponCode}'.", nameof(couponCode)),
    };

    // Discounts are rounded to the kuruş; 0.005 rounds up
    private static decimal PercentOf(decimal amount, decimal rate) =>
        Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero);
}
Passed!  - Failed:     0, Passed:     7, Skipped:     0, Total:     7, Duration: 8 ms - Bookstore.Discounts.Tests.dll (net10.0)

The tests didn't change at all and they are still green. What changed the code was not a new request but the questions I asked the four rules. The rounding comment stays too, because the answer to "why AwayFromZero" can't be read from the code; that is the "write what the code can't say" principle from the comments post.

Duplication or Coincidence?

The third rule is the easiest one to apply wrongly. Beck's original item says duplicated logic, not duplicated characters. DRY (Don't Repeat Yourself), named by Andy Hunt and Dave Thomas in The Pragmatic Programmer, is also about knowledge: every piece of knowledge should have a single, unambiguous, authoritative representation in the system. What Eric Raymond calls SPOT (Single Point Of Truth) in The Art of Unix Programming is the same thing.

Back to the opening question. The tax rate in the site's pricing is also 0.10m. It's easy to "remove the duplication" like this:

// Two separate business rules that happen to share a number
public static class Rates
{
    public const decimal TenPercent = 0.10m;
}

public static class Pricing
{
    public static decimal Tax(decimal net) => net * Rates.TenPercent;

    public static decimal WelcomeDiscount(decimal subtotal) => subtotal * Rates.TenPercent;
}

This code didn't reduce duplication, it tied two independent decisions together. The tax rate is changed by the government, the coupon rate by the marketing team. When one changes, whoever edits TenPercent changes the other without noticing; or the constant keeps the name TenPercent while its value becomes 0.20m and the name starts lying. The rule: if two pieces of code change for the same reason and at the same time, they are the same knowledge, merge them. If they change for different reasons, the similarity is a coincidence, keep them apart. The deeper discussion of this distinction, together with the view that the wrong abstraction is more expensive than duplication, is in the DRY, KISS (Keep It Simple, Stupid) and YAGNI post of the upcoming SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation and Dependency Inversion principles) and Design Principles series.

.NET itself has a real example of cleaning up duplication. Since 1.0, ASP.NET Core's authentication layer used its own ISystemClock interface for testable time. With .NET 8, a single TimeProvider abstraction arrived in the BCL (Base Class Library), and according to the breaking change note on Microsoft Learn, authentication made ISystemClock obsolete and moved to TimeProvider. The same note has a small detail: the old SystemClock truncated the time to the nearest second, TimeProvider doesn't. Even merging real duplication can change behavior; that's why the first rule always comes first.

Fewest Elements: The Price of Speculative Flexibility

Now the second implementation from the opening. It passes the same seven tests:

public interface IDiscountRule
{
    decimal Apply(DiscountContext context);
}

public sealed record DiscountContext(decimal Subtotal, string? CouponCode, CustomerTier Tier);

public abstract class PercentageDiscountRule(decimal rate) : IDiscountRule
{
    public decimal Apply(DiscountContext context) =>
        IsApplicable(context) ? Math.Round(context.Subtotal * rate, 2, MidpointRounding.AwayFromZero) : 0m;

    protected abstract bool IsApplicable(DiscountContext context);
}

public sealed class WelcomeCouponRule() : PercentageDiscountRule(0.10m)
{
    protected override bool IsApplicable(DiscountContext context) => context.CouponCode == "WELCOME10";
}

public sealed class VipRule() : PercentageDiscountRule(0.05m)
{
    protected override bool IsApplicable(DiscountContext context) => context.Tier == CustomerTier.Vip;
}

public sealed class SummerCouponRule : IDiscountRule
{
    public decimal Apply(DiscountContext context) => context.CouponCode == "SUMMER25" ? 25m : 0m;
}

public sealed class CouponValidationRule : IDiscountRule
{
    private static readonly HashSet<string> KnownCoupons = ["WELCOME10", "SUMMER25"];

    public decimal Apply(DiscountContext context) =>
        context.CouponCode is null || KnownCoupons.Contains(context.CouponCode)
            ? 0m
            : throw new ArgumentException($"Unknown coupon '{context.CouponCode}'.", nameof(context));
}

public static class DiscountRuleFactory
{
    public static IReadOnlyList<IDiscountRule> CreateDefaultRules() =>
        [new CouponValidationRule(), new WelcomeCouponRule(), new SummerCouponRule(), new VipRule()];
}

public sealed class DiscountCalculator(IReadOnlyList<IDiscountRule> rules)
{
    public DiscountCalculator() : this(DiscountRuleFactory.CreateDefaultRules())
    {
    }

    public decimal Calculate(decimal subtotal, string? couponCode, CustomerTier tier)
    {
        DiscountContext context = new(subtotal, couponCode, tier);
        return Math.Min(rules.Sum(rule => rule.Apply(context)), subtotal);
    }
}

Each piece looks reasonable on its own. The problem is the total: to understand how a discount is calculated you have to look at five types, find out from the factory in which order the rules are summed, and work out why CouponValidationRule returns 0 or throws. The unknown-coupon check is now disguised as a "discount rule". The fourth rule asks exactly this: which of the first three rules do these elements serve? They aren't needed to pass the tests, they don't make the intent clearer, and they don't reduce duplication; they are only waiting for a request that hasn't arrived yet. That is what YAGNI says.

This doesn't mean interfaces and the strategy pattern are bad. If the rules are to be switched on and off at runtime from an admin panel, if each tenant picks its own rule set, or if the switch expression becomes unreadable when the fifteenth rule arrives, refactoring to this structure that day takes an afternoon and the tests are already there. Doing it today makes every reader pay until that day. The IOrderProcessor<T> in the legacy OrderService.cs we've been cleaning up throughout the series is the same story: it was written "for different order types in the future", but neither the file nor the solution has a single implementation of it.

Common Pitfalls

Skipping the first rule. When you say "I'll write it clean first and add tests later", you have no safety net for the second and third rules. If you don't know the code you are refactoring still works, what you're doing isn't refactoring, it's rewriting.

Mistaking coincidental similarity for duplication. Two lines that look the same are not always the same knowledge. Before merging, ask "do these two change for the same reason?"

Turning "fewest elements" into a line-count contest. An explanatory variable, a named constant or a small private method adds an element, but if it serves the second rule it stays. The fourth rule has the lowest priority.

Treating the rules as a one-off checklist. The four rules are asked again at every small step. In Step 2 the intent was clear; in Step 3, with two new rates, it no longer was.

Reading emergent design as "let's not design". The rules work very well at small scale, at the method and class level. Decisions that are expensive to change, like service boundaries, the database schema or a public API (Application Programming Interface), still need thinking ahead. That's one of the criticisms John Ousterhout raised with Martin, and we'll talk about it two posts from now.

A Small Experiment

★☆☆ Add a BOOKLOVER coupon to the final version: 40 TL off carts over 300 TL. Write the test first, see it red, make it pass with the simplest code, then ask the four rules in order. Did it end up in the switch expression or in a separate method?

★★☆ Write a small TaxCalculator with a rate of 0.10m. Then write a two-sentence reply to a code review comment that asks it to share a constant with WelcomeCouponRate in DiscountCalculator, using the reasons for change as your argument.

★★★ Take the over-generalized version and simplify it back step by step according to the four rules: run the tests at every step, and for every type you delete note "which rule was it serving?". Did you end up at the single-class version, or did you find a piece worth keeping?

In this post we looked at duplication and unnecessary elements through the lens of the four rules. In the next post we'll learn to recognize the same problems by name: we'll group Martin Fowler and Kent Beck's code smell catalog with C# examples and try to find each one in the OrderService.cs we've been cleaning up throughout the series.