A team inherits a pricing method called Calc. Its parameters are o, r and t, its discounts are d1, d2 and d3, and it has charged customers correctly for years. A new rule is due next month, and nobody wants to be the one who edits it.

Refactoring is a change of structure checked against a recorded definition of behaviour, taken in steps small enough that a failure points at the step that caused it; without that record it is editing and hoping.

This part takes from Part 5 the shapes worth reaching, cohesive units whose dependencies point at abstractions, and asks how code that already runs gets there without breaking.

Where the word and the practice came from

A word older than the discipline

The word was in print before anyone made a method of it. Martin Fowler's note on the etymology records: "Bill Wake dug out the first known printed mention of the word "refactoring" in a Thinking Forth, a 1984 book by Leo Brodie." Fowler believes the Forth community arrived at the usage independently of the Smalltalk one, so the word has at least two beginnings.

The first sustained study came from the University of Illinois at Urbana-Champaign. William F. Opdyke's doctoral thesis, Refactoring Object-Oriented Frameworks, is dated 1992, and Ralph E. Johnson chaired its committee. Fowler calls it "the first thesis on refactoring". An earlier paper by Opdyke and Johnson, from 1990, is often cited as the first use of the term in the object-oriented literature. No source cited here confirms it, so it is not in the table below.

The book that fixed the vocabulary

Refactoring: Improving the Design of Existing Code was published by Addison-Wesley on 28 June 1999. Its author is Martin Fowler, with Kent Beck, John Brant, William Opdyke and Don Roberts. The foreword is by Erich Gamma, one of the four authors of the book in Part 4. The second edition, by Fowler with Beck, was published on 19 November 2018 and carries a 2019 copyright date, which is why both years are cited for it.

Beck's part is recorded in more than the list of contributors. Of the term code smell, Fowler writes: "The term was first coined by Kent Beck while helping me with my Refactoring book." Beck and Gamma also wrote JUnit in 1997, by Fowler's account on a flight from Zurich to the OOPSLA conference in Atlanta, and Beck's Test Driven Development: By Example followed on 8 November 2002. A technique defined by behaviour that does not change needs a cheap way to check behaviour, and the testing tools arrived in the same years, from some of the same people.

Code that has no tests yet

Michael Feathers supplied the technique for code whose behaviour nobody has written down. Working Effectively with Legacy Code was published on 22 September 2004 with a 2005 copyright date. The book is widely quoted as defining legacy code as code without tests. No source cited here confirms the wording, so it is reported as the common attribution and not as a quotation.

What is verified is Feathers's own later description of the technique the book is known for. In his essay of 8 August 2016, "Characterization Testing", he writes: "When we write characterization tests we build up our knowledge of what the code actually does." The subject is what the code does, not what it ought to do. Feathers writes the word with a z; the example's test names use the British spelling.

DateWhoWhat, in the original termsSource
1984Leo BrodieThinking Forth, the first known printed use of the word refactoring, found by Bill WakeFowler, EtymologyOfRefactoring
1992William F. OpdykeDoctoral thesis Refactoring Object-Oriented Frameworks, University of Illinois at Urbana-Champaign; committee chair Ralph E. JohnsonUniversity of Illinois IDEALS record
1997Kent Beck and Erich GammaJUnit, written on a flight from Zurich to OOPSLA in AtlantaFowler, Xunit
28 June 1999Martin Fowler, with Kent Beck, John Brant, William Opdyke and Don RobertsRefactoring: Improving the Design of Existing Code, first edition, foreword by Erich GammaPublisher's page
8 November 2002Kent BeckTest Driven Development: By Example (copyright 2003)Publisher's page
22 September 2004Michael FeathersWorking Effectively with Legacy Code (copyright 2005)Publisher's page
8 August 2016Michael FeathersThe essay Characterization TestingFeathers's site
19 November 2018Martin Fowler, with Kent BeckRefactoring, second edition (copyright 2019)Publisher's page

None of this replaced the ideas of the earlier parts. Refactoring has no view of its own on what good structure is: the criteria still come from Parnas's hidden decisions in Part 2 and from the principles of Part 5. It adds a way of moving between two structures while the program keeps working.

What the definition requires

Fowler defines the word twice, in the 1999 book and again on his site on 1 September 2004, and the condition is the same both times. As a noun, a refactoring is "a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behavior".

As a verb, to refactor is "to restructure software by applying a series of refactorings without changing its observable behavior". His site refactoring.com says external behaviour where the book says observable behaviour, and calls the whole a disciplined technique.

Three requirements follow. The change is to structure: a new rule or a corrected rate is a different kind of change, however good. Behaviour is held constant, which means somebody has said what the behaviour is. And the work is a series: each step is small and the check runs after each one, so a failure has one suspect.

Behaviour needs a definition

The second requirement is the hard one, because observable depends on the observer. A caller sees return values and exceptions. An operator sees log lines and timings. A subclass sees which methods exist. No definition covers everything, so in practice the tests are the working definition: behaviour is what the tests assert, and anything they do not assert can change without anyone being told. The example shows both sides of that line.

The code a team inherits

Stage 08 of the example holds two classes that compute the same pricing rules as every other stage: LegacyPricing and RefactoredPricing, in src/Pricing/Stage08Refactoring. The legacy class is one method. This is its first half:

C#
// t: true = with tax, false = without
public PriceBreakdown Calc(OrderData o, decimal r, bool t)
{
    if (o == null || o.Lines == null || o.Lines.Count == 0) throw new ArgumentException("bad order");
    if (r < 0 || r > 1) throw new ArgumentException("bad rate");
    for (int i = 0; i < o.Lines.Count; i++)
    {
        if (o.Lines[i].Quantity < 1) throw new ArgumentException("bad qty");
        if (o.Lines[i].UnitPrice < 0) throw new ArgumentException("bad price");
    }
    decimal s = 0;
    for (int i = 0; i < o.Lines.Count; i++)
    {
        s = s + o.Lines[i].UnitPrice * o.Lines[i].Quantity;
    }
    decimal d1 = 0;
    if (s >= 5000) d1 = Math.Round(s * 0.10m, 2, MidpointRounding.AwayFromZero);
    else if (s >= 1000) d1 = Math.Round(s * 0.05m, 2, MidpointRounding.AwayFromZero);
    decimal d2 = 0;
    if ((int)o.Tier == 1) d2 = Math.Round(s * 0.03m, 2, MidpointRounding.AwayFromZero);
    else if ((int)o.Tier == 2) d2 = Math.Round(s * 0.07m, 2, MidpointRounding.AwayFromZero);
    else if ((int)o.Tier != 0) throw new ArgumentException("bad tier");
    decimal d3 = 0;
    if (o.CouponCode != null && o.CouponCode != "")
    {
        if (o.CouponCode != "WELCOME10") throw new ArgumentException("bad coupon");
        if (o.IsFirstOrder) d3 = Math.Round(s * 0.10m, 2, MidpointRounding.AwayFromZero);
    }
    decimal d = d1 + d2 + d3;
    if (d > Math.Round(s * 0.20m, 2, MidpointRounding.AwayFromZero))
    {
        d = Math.Round(s * 0.20m, 2, MidpointRounding.AwayFromZero);
    }

And its second half:

C#
    decimal sh;
    decimal tx;
    decimal tot;
    if (t)
    {
        if (s - d >= 200) sh = 0; else sh = 7.50m;
        tx = Math.Round((s - d + sh) * r, 2, MidpointRounding.AwayFromZero);
        tot = Math.Round(s - d + sh + tx, 2, MidpointRounding.AwayFromZero);
    }
    else
    {
        if (s - d >= 200) sh = 0; else sh = 7.50m;
        tx = 0;
        tot = Math.Round(s - d + sh, 2, MidpointRounding.AwayFromZero);
    }
    List<DiscountLine> l = new List<DiscountLine>();
    l.Add(new DiscountLine("volume", d1));
    l.Add(new DiscountLine("loyalty", d2));
    l.Add(new DiscountLine("coupon", d3));
    return new PriceBreakdown(s, l, d, sh, tx, tot);
}

The lines are walked twice, once to validate and once to add. The same rounding call is written out ten times. The value 0.10 appears twice and means two different things, the large-volume rate and the coupon rate. The tier is compared with the integers 1 and 2, which are correct only because of the order in which the CustomerTier enumeration declares its members. The flag t selects between two branches that repeat the shipping rule.

It is also correct. The host prices three sample orders with every stage, and the legacy method agrees with all of them:

Text
Stage 08 Legacy                | small  | subtotal    99.90 | discount     0.00 | shipping 7.50 | tax    24.70 | total   132.10
Stage 08 Legacy                | mixed  | subtotal  2000.00 | discount   360.00 | shipping 0.00 | tax   377.20 | total  2017.20
Stage 08 Legacy                | capped | subtotal  6000.00 | discount  1200.00 | shipping 0.00 | tax  1104.00 | total  5904.00

That is the usual position with inherited code. The defect is not in what it computes but in what the next change will cost.

Pin the behaviour before changing anything

The first edit in Stage 08 is not to LegacyPricing. It is a test file, tests/Pricing.Tests/Stage08CharacterisationTests.cs, written against the legacy method as it stands. The tests live in an abstract class that does not know which implementation it is testing:

C#
public abstract class Stage08Characterisation
{
    protected abstract PriceBreakdown WithTax(OrderData order, decimal taxRate);
    protected abstract PriceBreakdown WithoutTax(OrderData order);

    private static OrderData Order(decimal amount, CustomerTier tier, bool first = false, string? coupon = null) =>
        new([new("SKU-1", amount, 1)], tier, first, coupon);

    [Fact]
    public void Characterisation_a_small_standard_order_pays_shipping_and_tax()
    {
        PriceBreakdown price = WithTax(Order(99.90m, CustomerTier.Standard), 0.23m);

        Assert.Equal((0m, 7.50m, 24.70m, 132.10m), (price.TotalDiscount, price.Shipping, price.Tax, price.Total));
    }

    [Fact]
    public void Characterisation_discounts_are_reported_in_the_order_volume_loyalty_coupon()
    {
        PriceBreakdown price = WithTax(Order(2000.00m, CustomerTier.Silver, first: true, "WELCOME10"), 0.23m);

        Assert.Equal(
            [new("volume", 100.00m), new("loyalty", 60.00m), new("coupon", 200.00m)],
            price.Discounts);
        Assert.Equal(2017.20m, price.Total);
    }

    [Fact]
    public void Characterisation_the_discount_stops_at_a_fifth_of_the_subtotal()
    {
        PriceBreakdown price = WithTax(Order(6000.00m, CustomerTier.Gold, first: true, "WELCOME10"), 0.23m);

        Assert.Equal(1200.00m, price.TotalDiscount);
        Assert.Equal(5904.00m, price.Total);
    }

    [Fact]
    public void Characterisation_without_tax_the_total_is_the_discounted_subtotal_plus_shipping()
    {
        PriceBreakdown price = WithoutTax(Order(101.50m, CustomerTier.Silver));

        // L = 3.045 -> 3.05. A = 98.45. Sh = 7.50. No tax. Tot = 105.95.
        Assert.Equal((3.05m, 7.50m, 0m, 105.95m), (price.TotalDiscount, price.Shipping, price.Tax, price.Total));
    }

The region shows four of the class's seven tests. The expected values are what the legacy method returns, not what a specification says. The fourth test is a good specimen: 3% of 101.50 is 3.045, the code rounds midpoints away from zero, and so the test pins 3.05. A reader who expected rounding to even would call that a bug. The characterisation test does not take a side. It records.

The three tests outside the region pin more of the same kind:

  • Characterisation_a_coupon_on_a_later_order_is_accepted_and_gives_nothing. The coupon WELCOME10 on an order that is not the customer's first is neither rejected nor honoured. It looks like an oversight; it is rule 5 of the example's README, and the test holds it either way.
  • Characterisation_invalid_input_is_rejected_with_an_argument_exception. Six invalid inputs, each required to throw ArgumentException or a class derived from it. The test asks for nothing more precise than that.
  • Characterisation_without_tax_equals_with_tax_at_a_rate_of_zero. For every order in the shared table, the flag set to false gives the same result as the flag set to true with a rate of zero. This one is a question put to the legacy code, because a later step depends on the answer.

Two small classes then bind the abstract tests to real code:

C#
public sealed class LegacyPricingCharacterisationTests : Stage08Characterisation
{
    private readonly LegacyPricing _legacy = new();

    protected override PriceBreakdown WithTax(OrderData order, decimal taxRate) => _legacy.Calc(order, taxRate, true);
    protected override PriceBreakdown WithoutTax(OrderData order) => _legacy.Calc(order, 0.23m, false);
}

public sealed class RefactoredPricingCharacterisationTests : Stage08Characterisation
{
    private readonly RefactoredPricing _refactored = new();

    protected override PriceBreakdown WithTax(OrderData order, decimal taxRate) => _refactored.Price(order, taxRate);
    protected override PriceBreakdown WithoutTax(OrderData order) => _refactored.PriceBeforeTax(order);
}

The first class comes first: it has to pass before a line of LegacyPricing moves. The second is the reason for the arrangement: the same seven tests, unedited, run against whatever the code becomes. A test that had to be edited to pass would be evidence that behaviour had changed.

Seven steps, each followed by the tests

The comment at the top of RefactoredPricing.cs lists the steps from one class to the other. The names in the table are the ones that comment uses.

StepRefactoringWhat it did hereThe risk in the step
1Renameo, r, s, d1, sh and tx became order, taxRate, subtotal, volume, shipping and taxA name that states the wrong meaning will be believed
2Extract FunctionThe ten identical Math.Round calls became RoundMoneyA call with other digits or another midpoint mode is replaced with the rest
3Replace Magic Number1000, 0.05, 200, 7.50 and the others became named constantsTwo equal numbers with different meanings receive one name
4Merge duplicate loopsThe validating loop and the summing loop became SubtotalThe order of checks and arithmetic changes
5Extract FunctionOne function for each discount, for the cap and for shippingA fragment reads or writes a variable that the new function is not given
6Consolidate duplicatesThe shipping rule, repeated in both branches of the flag, was written onceBranches that are nearly the same are merged as if identical
7Remove Flag ArgumentCalc(o, r, false) became PriceBeforeTax(order)Every caller changes, and the two modes must differ only in the tax

The constants come from step 3. The value 0.10 received two names, LargeVolumeRate and WelcomeRate, because it stood for two rules that only happen to agree today:

C#
private const decimal SmallVolumeFrom = 1000m, SmallVolumeRate = 0.05m;
private const decimal LargeVolumeFrom = 5000m, LargeVolumeRate = 0.10m;
private const decimal SilverRate = 0.03m, GoldRate = 0.07m;
private const string WelcomeCoupon = "WELCOME10";
private const decimal WelcomeRate = 0.10m;
private const decimal DiscountCapRate = 0.20m;
private const decimal ShippingFee = 7.50m, FreeShippingFrom = 200m;

Step 4 produced one pass over the lines in place of two:

C#
private static decimal Subtotal(OrderData order)
{
    if (order.Lines.Count == 0)
    {
        throw new ArgumentException("An order needs at least one line.", nameof(order));
    }

    decimal subtotal = 0m;
    foreach (OrderLineData line in order.Lines)
    {
        if (line.Quantity < 1) throw new ArgumentException("A quantity is at least 1.", nameof(order));
        if (line.UnitPrice < 0m) throw new ArgumentException("A unit price cannot be negative.", nameof(order));
        subtotal += line.UnitPrice * line.Quantity;
    }
    return subtotal;
}

The entry points are what steps 1, 5 and 7 leave behind. Price now reads as the list of rules, and the flag has gone:

C#
public PriceBreakdown Price(OrderData order, decimal taxRate = 0.23m)
{
    ArgumentNullException.ThrowIfNull(order);
    if (taxRate < 0m || taxRate > 1m)
    {
        throw new ArgumentOutOfRangeException(nameof(taxRate), "The tax rate lies between 0 and 1.");
    }

    decimal subtotal = Subtotal(order);
    decimal volume = VolumeDiscount(subtotal);
    decimal loyalty = LoyaltyDiscount(order.Tier, subtotal);
    decimal coupon = CouponDiscount(order, subtotal);
    decimal discount = CapDiscount(volume + loyalty + coupon, subtotal);
    decimal shipping = Shipping(subtotal - discount);
    decimal tax = RoundMoney((subtotal - discount + shipping) * taxRate);
    decimal total = RoundMoney(subtotal - discount + shipping + tax);

    return new PriceBreakdown(
        subtotal,
        [new("volume", volume), new("loyalty", loyalty), new("coupon", coupon)],
        discount, shipping, tax, total);
}

// Step 7: the flag is gone. A price before tax is a price at a tax rate of zero.
public PriceBreakdown PriceBeforeTax(OrderData order) => Price(order, taxRate: 0m);

Step 7 rests on a characterisation test. In the legacy code the false branch sets the tax to zero and rounds the total without it. Replacing that branch with a call at a rate of zero is safe only if the two give the same result for every order, and Characterisation_without_tax_equals_with_tax_at_a_rate_of_zero establishes that against LegacyPricing.

Step 5 gave each rule a function:

C#
private static decimal VolumeDiscount(decimal subtotal)
{
    if (subtotal >= LargeVolumeFrom) return RoundMoney(subtotal * LargeVolumeRate);
    if (subtotal >= SmallVolumeFrom) return RoundMoney(subtotal * SmallVolumeRate);
    return 0m;
}

private static decimal LoyaltyDiscount(CustomerTier tier, decimal subtotal) => tier switch
{
    CustomerTier.Standard => 0m,
    CustomerTier.Silver => RoundMoney(subtotal * SilverRate),
    CustomerTier.Gold => RoundMoney(subtotal * GoldRate),
    _ => throw new ArgumentOutOfRangeException(nameof(tier), "Unknown customer tier."),
};

private static decimal CouponDiscount(OrderData order, decimal subtotal)
{
    if (string.IsNullOrEmpty(order.CouponCode)) return 0m;
    if (order.CouponCode != WelcomeCoupon) throw new ArgumentException("Unknown coupon.", nameof(order));
    return order.IsFirstOrder ? RoundMoney(subtotal * WelcomeRate) : 0m;
}

private static decimal CapDiscount(decimal discount, decimal subtotal) =>
    Math.Min(discount, RoundMoney(subtotal * DiscountCapRate));

Two things in that block are restatements. The integers 1 and 2 are now CustomerTier.Silver and CustomerTier.Gold. The cap, which the legacy code computed twice and compared, is one Math.Min. Shipping and rounding complete the class:

C#
private static decimal Shipping(decimal subtotalAfterDiscount) =>
    subtotalAfterDiscount >= FreeShippingFrom ? 0m : ShippingFee;

private static decimal RoundMoney(decimal amount) =>
    Math.Round(amount, 2, MidpointRounding.AwayFromZero);

What changed and what did not

What changed is everything a maintainer reads: names, the number of functions, where each number is written, the public signatures. What did not change is every value returned for every order the tests hold.

The refactoring also introduced no interface, no policy object and no injected dependency. The loyalty discount is still a conditional, now a switch expression over three tiers. Stage 05 gives each discount a class of its own, but nothing in this class varies independently, so the conditional is the simpler and better form here. As the comment in the file says, the destination resembles Stage 02 with the constants of Stage 03. Refactoring moves code towards a design and does not choose it. In our judgement the nearest adequate design is usually the place to stop.

The proof that the two agree

Three tests carry the claim, and each proves a different part of it.

  • LegacyPricingCharacterisationTests and RefactoredPricingCharacterisationTests, in Stage08CharacterisationTests.cs: the seven characterisation tests pass against both classes.
  • StageEquivalenceTests.Every_stage_prices_every_order_in_the_table_identically: the table of 22 orders with hand-computed results runs against all 13 entries of the stage catalogue, and both Stage 08 classes are among them. The legacy class is entered with its flag set to true.
  • InvalidInputTests.Every_stage_rejects_invalid_input: nine invalid inputs, rejected by both classes with an exception assignable to ArgumentException.

The host shows the same agreement for the three sample orders:

Text
Stage 08 Refactored            | small  | subtotal    99.90 | discount     0.00 | shipping 7.50 | tax    24.70 | total   132.10
Stage 08 Refactored            | mixed  | subtotal  2000.00 | discount   360.00 | shipping 0.00 | tax   377.20 | total  2017.20
Stage 08 Refactored            | capped | subtotal  6000.00 | discount  1200.00 | shipping 0.00 | tax  1104.00 | total  5904.00

This is proof of a particular kind. No test feeds the two classes arbitrary orders and compares their answers. Both are compared with the same expected values, over a finite table chosen to sit on the boundaries of the rules. And one test changes its nature after the refactoring. PriceBeforeTax is defined as Price at a rate of zero, so against the refactored class the test that compares the two is true by construction. Its work was done on the legacy side.

What the tests do not see

Reading the two classes side by side shows differences that every test passes over.

  • Exception types and messages. The legacy method throws a plain ArgumentException with messages such as bad order and bad rate. The refactored class throws ArgumentNullException, ArgumentOutOfRangeException or ArgumentException, with other messages. The tests ask only for the base type. A caller that matched on a message would break.
  • A null list of lines. The legacy method checks o.Lines == null and rejects it. The refactored Subtotal reads order.Lines.Count without that check, so a null list would surface as a NullReferenceException, which is not an ArgumentException. The type declares the list as non-nullable, which is a compiler warning and not a run-time guarantee. No test passes a null list.
  • The tax rate in the mode that ignores it. Calc validates r before it looks at the flag, so a call with the flag false and a rate of 1.5 is rejected for a rate it would never have used. PriceBeforeTax takes no rate. The characterisation tests always pass 0.23 on that path, so this oddity was neither pinned nor preserved.
  • The order of the checks. An order with no lines and an invalid rate is rejected by the legacy method for the order and by the refactored class for the rate.

By the strict definition, some of these are changes of observable behaviour. In our judgement each is an improvement and none matters to a caller that passes valid orders. That is a judgement about callers, and the tests cannot make it. Where you do not control the callers, find out what they depend on and pin that as well.

A suite that has never failed proves little

A passing test is evidence only if it would have failed had the code been wrong. Characterisation tests are especially exposed, because they are written after the code and pass from their first run. Nobody has seen them fail.

The example's counterproof.sh supplies the missing observation. It copies the solution to a temporary directory and requires the unmutated tests to pass there. Then it applies one edit at a time to the copy, rebuilds, runs the tests and requires them to fail. A mutant that does not compile, or an edit that changes nothing, counts as not caught. The working tree is never modified.

The script contains thirteen mutations: a rate, the discount cap, the first-order condition, the shipping threshold and the rounding mode, in several stages. Two are inside RefactoredPricing. One makes CapDiscount take the larger amount in place of the smaller. The other makes Shipping compare with > in place of >=. When the example was built on 13 November 2026, all thirteen were caught.

None of the seven characterisation tests prices an order at exactly 200.00 after discount. The row that does is in the shared order table, in OrderCases.cs, with a neighbour one cent below. Without the table, the second mutation would have survived the tests written to protect this class.

Thirteen hand-chosen edits are not a measure of coverage. The script never mutates LegacyPricing, never touches an exception path, and tries one edit at a time. It shows that the suite can fail for the kinds of mistake a refactoring step makes, not that it would fail for all of them.

When not to refactor, and what it costs

Without tests. Fowler's definition cannot be shown to hold when nothing checks behaviour: the result may be better code, but nobody can say it is the same code. Stage 08 follows the order Feathers describes, characterisation first. Where even that is impractical, the accurate name for the work is a partial rewrite.

When nothing is about to change. LegacyPricing is ugly and right. If no rule is due and nobody needs to read it, we would leave it alone: its ugliness costs something only when someone has to change it.

When a rewrite is the better answer. This is our judgement, not a rule. Refactoring preserves behaviour, so it is the wrong tool when the behaviour itself is wrong, or when no test can be attached to the code at an acceptable price. It is the right tool when the code is correct and merely expensive to change, because it keeps every decision already made, including the ones nobody remembers making. A rewrite pays for those forgotten decisions: the rate rejected in a mode that ignores it is the sort of thing a rewrite loses or keeps without anyone choosing.

As a label for something else. By the definition, work that changes what the program does is not refactoring, whatever the plan calls it. Mixing a behaviour change into a structural one means a failing test has two suspects. We would keep them in separate commits, with the tests unchanged in the structural one.

The cost. The refactored file is longer than the legacy one. It has seven private functions to navigate where there was one method to scroll, and callers had to change, because the entry point's name and parameters changed. Those costs are small here and repaid at the first new rule. In a codebase with many callers, step 7 alone can be the largest part of the work.

Once code can be moved safely, the next question is where it should go. Part 7 takes that up: where the boundaries of a system belong. One of its sources is Eric Evans's Domain-Driven Design, whose third part is titled "Refactoring Toward Deeper Insight".

What to take from this

  • Before changing inherited code, write tests that record what it does today, including what looks wrong. Decide separately, and later, whether to change any of it.
  • Put those tests behind an abstract class or a function, so the same tests run unedited against the code before and after. A test edited to pass is a behaviour change.
  • Take one named step at a time and run the tests after each. When they fail, undo the step instead of debugging it.
  • Break the code on purpose at least once and watch the tests fail. If they do not, they were not protecting you.
  • List what your tests do not assert, such as messages, exception types, null handling and the order of checks, and find out whether any caller depends on it.
  • Stop at the nearest design that makes the next change easy. An interface nobody varies is not an improvement.

Run the code

The example is one download, software-foundations-example.zip: unpack it and run the commands in its foundations folder. It was built and its tests were run with the .NET 10 SDK on 13 November 2026, and it needs that SDK. .NET 10 is a long-term support release; .NET 11 was released on 10 November 2026 and the example does not need it.

  • dotnet test runs the tests.
  • dotnet run --project src/Pricing.Host prints the output shown above.

The tests that cover what this article showed:

  • Stage08CharacterisationTests.cs: seven characterisation tests in the abstract class Stage08Characterisation, run against LegacyPricing by LegacyPricingCharacterisationTests and against RefactoredPricing by RefactoredPricingCharacterisationTests.
  • StageEquivalenceTests.Every_stage_prices_every_order_in_the_table_identically: both Stage 08 classes against the 22 orders of OrderCases.cs.
  • InvalidInputTests.Every_stage_rejects_invalid_input: both classes reject every invalid input, by exception type only.
  • HostOutputTests.The_host_prints_exactly_the_committed_expected_output: the output lines quoted above.

./counterproof.sh changes one rule at a time in a copy of the solution and requires the tests to fail. Two of its thirteen changes fall in the code shown here, both in RefactoredPricing: CapDiscount taking the larger amount, and Shipping comparing with > in place of >=.

References

Tags#software-foundations#software-design#engineering#dotnet#development#havecode

The Foundations of Software Engineering

From the origins of programming to modern architecture. 8 parts, read in order.

  1. 01
    Before Design Patterns: How Software Engineering Learned to Manage Complexity

    From Lovelace's Notes of 1843 to Brooks's essay of 1986, each advance in programming removed a difficulty of the tools and left the difficulty of the problem. One pricing routine, written three ways with identical output, shows what structure and names buy and what they do not.

  2. 02
    Structured Programming, Modularity and Information Hiding

    Structured control flow, procedures, modules, information hiding, coupling, cohesion and separation of concerns are separate ideas from separate authors. The same pricing rules, cut as functions and then as modules, show what Parnas's criterion localises and what it does not.

  3. 03
    Object-Oriented Programming: Origins, Promises and Trade-offs

    Object orientation is several mechanisms with separate origins: Simula's subclasses, abstract data types, Smalltalk's messaging. One pricing example shows what encapsulation and an interface buy, what they cost, and when functions over plain data stay the better design.

  4. 04
    The Gang of Four: 23 Design Patterns and the Problems They Solve

    The 23 patterns of the 1994 book, each as a problem, a shape, a cost and its modern C# form, in one pricing example. Most are a single move, an interface placed where something varies, and the example needed only one of them.

  5. 05
    SOLID, Coupling, Cohesion and Dependency Inversion

    Coupling and cohesion are the criteria; the five SOLID principles are later heuristics with separate origins in Meyer, Liskov and Martin. One pricing calculator shows what each buys and costs, and why dependency inversion, dependency injection and a container are three different things.

  6. 06
    Refactoring: Improving Design Without Changing Behaviour

    You are here.

  7. 07
    From Design Patterns to Domain-Driven Design and Modern Architecture

    Domain-driven design, ports and adapters, the onion and the clean architecture share one rule: the business rules refer to nothing else. The pricing example gains a domain model and a tested boundary, and needs 22 types where one class did.

  8. 08
    Do We Still Need Gang of Four Patterns in Modern C# and .NET?

    All 23 patterns set against C# 14 and .NET 10: eleven have a shorter form in the example, built from a language or platform feature, and twelve do not. The problems remain in every case, and each modern form has a failure of its own.

Support independent engineering knowledge

Knowledge, Analysis, the Field Guide and the tools are free to read and use, and they stay that way. If this article saved you time, you can support the research and writing behind it. Support is voluntary and unlocks nothing.

Buy me a coffee (opens in a new tab)João Lívio's page on buymeacoffee.com

Comments