Mert Özen Further With Every Line
Software Design Refactoring — #06

Planning a Large Refactoring: The Mikado Method, Branch by Abstraction and Parallel Run

Mert Özen Nov 14, 2026 13 min 2 views
Planning a Large Refactoring: The Mikado Method, Branch by Abstraction and Parallel Run

A refactoring that doesn't fit in one PR is planned with a prerequisite graph found by trying and reverting with the Mikado Method, and carried out on the main branch with branch by abstraction in small commits that each stay green. The post replaces the static LegacyShippingClient with a typed HttpClient-based client in 13 commits; a parallel run catches two differences no test covered, and real output shows how static state breaks parallel tests.

Version note: The code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402), with tests on xUnit.net v3 4.0.3 and Microsoft.Extensions.Diagnostics.Testing 10.11.0; the typed client is set up with Microsoft.Extensions.Http 10.0.15.

Guess First: Is This an Afternoon's Work?

Bookstore's carrier announced that it will shut down its old XML service; a v2 API that speaks JSON replaces it. On our side, the only place that talks to the carrier is a static class from 2014:

// Carrier's XML service, called synchronously since 2014.
public static class LegacyShippingClient
{
    public static string Endpoint { get; set; } = "https://carrier.example/xml";

    public static decimal GetRate(string postalCode, decimal weightKg)
    {
        XElement response = Post("GetRate",
            new XElement("RateRequest",
                new XElement("PostalCode", postalCode),
                new XElement("WeightKg", weightKg)));
        return decimal.Parse(response.Element("Amount")!.Value, CultureInfo.InvariantCulture);
    }

    public static string CreateShipment(int orderId, string postalCode, decimal weightKg) { /* ... */ }

    private static XElement Post(string action, XElement body)
    {
        WebRequest request = WebRequest.Create(Endpoint);
        // ... synchronous GetRequestStream / GetResponse
    }
}

The compiler has been building this class with the same warning for years:

LegacyShippingClient.cs(34,30): warning SYSLIB0014: 'WebRequest.Create(string)' is obsolete: 'WebRequest, HttpWebRequest, ServicePoint, and WebClient are obsolete. Use HttpClient instead.'

Three places call it: CheckoutService, which calculates the shipping fee for a cart, FulfillmentService, which creates a shipment, and ReturnService, which quotes the return fee. All three are created in minimal API endpoints as new CheckoutService(). The goal is clear: shipping calls should go to the new API through a typed HttpClient, and LegacyShippingClient should be deleted. Three services, three endpoints and one static class. Do you think this fits in a single PR, in one afternoon?

The Mikado Method: Try, Take Notes, Revert

The method that Ola Ellnestam and Daniel Brolund describe in The Mikado Method (Manning, 2014) bases the planning of a large change on experiments instead of estimates. The loop is short: try to implement the goal directly; write everything that breaks into a graph as a prerequisite of the goal; then revert your change. Repeat the same loop for each prerequisite. When you reach a node with no prerequisites, a leaf, you implement it for real, commit it and work your way up the graph. The name comes from the game of Mikado, where you pull sticks out one by one without moving the others.

I made the first attempt in its most naive form: I wrote a new CarrierApiClient and wired CheckoutService straight to it.

public class CheckoutService(CarrierApiClient carrier)
{
    public Task<decimal> QuoteShippingAsync(Cart cart, CancellationToken cancellationToken = default) =>
        carrier.GetRateAsync(cart.PostalCode, cart.WeightKg, cancellationToken);
}
src/Bookstore.Web/Program.cs(10,35): error CS7036: There is no argument given that corresponds to the required parameter 'carrier' of 'CheckoutService.CheckoutService(CarrierApiClient)'
tests/Bookstore.Shipping.Tests/LegacyBehaviorTests.cs(14,36): error CS7036: There is no argument given that corresponds to the required parameter 'carrier' of 'CheckoutService.CheckoutService(CarrierApiClient)'

Two errors, two notes: the endpoints create the service with new, and so do the tests. This is where Mikado gets tested. You feel the urge to fix the error right away; I went one step further, added AddHttpClient<CarrierApiClient> and AddScoped<CheckoutService>() to Program.cs and made the endpoint async. The web project compiled, but new questions came up. QuoteShippingAsync now returns a Task, so every caller has to become async. What will the tests build the new client against? The characterization tests only know the old XML service; nothing shows that the new API gives the same price. The new client doesn't even have a counterpart to CreateShipment for FulfillmentService.

At this point I wrote the notes into the graph and reverted everything:

$ git restore . && git clean -fd src
$ git status --short
$

Reverting looks like waste, but it is the core of the method. If you pile fixes on top of broken code, an hour later you can't tell what broke and why, and you have no green point to return to. The ten-minute experiment didn't stay in the code, but it stayed in the graph.

The Answer: The Leaves of the Graph and the Order of Work

06-mikado-bba-en.png

No, it isn't an afternoon's work; but that isn't a problem, because now the work has a map. The goal has three prerequisites: the services must get the carrier through their constructors, the callers must be async, and it must be shown that the new client gives the same results as the old one. Under the first two there are two leaves: endpoints that resolve the services from DI, and an abstraction that both implementations can sit behind. Under the third are the contract tests and a parallel run on production traffic.

The leaves are independent, small changes that each stay green on their own. The numbers in the figure show the order of work. Note that the graph doesn't describe a single PR: each node is one or two commits, and each commit is ready to go to the main branch.

Branch by Abstraction: Putting the Abstraction in Between

The first leaf is the abstraction that lets two implementations live side by side. According to Martin Fowler's 2014 BranchByAbstraction article, the technique got its name from Paul Hammant, who credits the idea to Stacy Curl. Don't let the word "branch" mislead you: no branch is created in source control; the branching happens inside the code, behind an interface. We chose the abstraction's signature based on where we want to end up, not on the old code: methods that are async and take a CancellationToken.

public interface IShippingCarrier
{
    Task<decimal> GetRateAsync(string postalCode, decimal weightKg, CancellationToken cancellationToken = default);

    Task<string> CreateShipmentAsync(int orderId, string postalCode, decimal weightKg, CancellationToken cancellationToken = default);
}

// Temporary: the old static client behind the new abstraction. Still blocks a thread on every call.
public sealed class LegacyShippingCarrier : IShippingCarrier
{
    public Task<decimal> GetRateAsync(string postalCode, decimal weightKg, CancellationToken cancellationToken = default) =>
        Task.FromResult(LegacyShippingClient.GetRate(postalCode, weightKg));

    public Task<string> CreateShipmentAsync(int orderId, string postalCode, decimal weightKg, CancellationToken cancellationToken = default) =>
        Task.FromResult(LegacyShippingClient.CreateShipment(orderId, postalCode, weightKg));
}

A synchronous HTTP call inside Task.FromResult isn't nice code; it looks async but blocks the thread. This is a deliberate intermediate step: the abstraction's signature is right, its inside is temporary. In this commit nobody uses IShippingCarrier and behavior doesn't change. The second leaf is having the endpoints get the services from the DI container. After that the services moved to the abstraction one by one. The CheckoutService commit is almost the same as the change we saw in the Mikado experiment; the only difference is that this time the constructor gets the abstraction, not a concrete client, and the DI registration points to the old implementation:

+builder.Services.AddSingleton<IShippingCarrier, LegacyShippingCarrier>();
 builder.Services.AddScoped<CheckoutService>();
-app.MapPost("/checkout/shipping-quote", (Cart cart, CheckoutService checkout) =>
-    Results.Ok(new { amount = checkout.QuoteShipping(cart) }));
+app.MapPost("/checkout/shipping-quote", async (Cart cart, CheckoutService checkout, CancellationToken ct) =>
+    Results.Ok(new { amount = await checkout.QuoteShippingAsync(cart, ct) }));

After this commit the shipping fee still comes from the old XML service, and the tests are still green. The same step was repeated for ReturnService and FulfillmentService. Now no service knows LegacyShippingClient directly; the adapter is the only place that does.

Two Implementations, the Same Tests

Before writing the new client, we decided what would tell us it is correct. We turned the characterization tests into a base class that gets the implementation from an abstract method. The same tests run for each implementation through its own subclass:

public abstract class ShippingCarrierContract(CarrierFixture fixture)
{
    protected abstract IShippingCarrier CreateCarrier();

    [Theory]
    [InlineData("34000", 0.8, 59.90)]
    [InlineData("06100", 2.5, 94.90)]
    [InlineData("21000", 1.0, 89.90)]
    public async Task Checkout_quotes_the_carrier_rate(string postalCode, decimal weightKg, decimal expected)
    {
        Cart cart = new(postalCode, [new CartLine("9780441013593", 1, weightKg)]);

        Assert.Equal(expected, await new CheckoutService(CreateCarrier()).QuoteShippingAsync(cart, TestContext.Current.CancellationToken));
    }
    // ... return fee and single shipment tests
}

public class LegacyShippingCarrierTests(CarrierFixture fixture) : ShippingCarrierContract(fixture)
{
    protected override IShippingCarrier CreateCarrier() => new LegacyShippingCarrier();
}

The tests don't go to the real carrier but to a small fake service that starts on Kestrel inside the test process; it serves both the old XML endpoint and the v2 JSON API from the same price list. Then we wrote the typed client: HttpShippingCarrier, an ordinary class that takes an HttpClient through its constructor and goes to v2/rates and v2/shipments with PostAsJsonAsync. It got its own subclass too. The first run was red for a reason that had nothing to do with the new code, and not every time, only now and then:

failed Bookstore.Shipping.Tests.LegacyShippingCarrierTests.Ship_creates_exactly_one_shipment (3ms)
  Assert.Equal() Failure: Values differ
  Expected: 1
  Actual:   0
failed Bookstore.Shipping.Tests.HttpShippingCarrierTests.Ship_creates_exactly_one_shipment (3ms)
  Assert.Equal() Failure: Values differ
  Expected: 1
  Actual:   2

The cause is that LegacyShippingClient.Endpoint is static. When the two test classes run in parallel, each starts its own fake service and points the static address at it; the one that starts later wins, and the old client's shipment lands on the other service. This is how legacy static state shows up on the first day you run tests in parallel. The fix is to put both classes into a single xUnit collection that shares one fake service; tests inside a collection run one after another. This constraint is a temporary cost we pay until the static client is deleted.

Parallel Run: The Old One Answers, the New One Is Measured

The tests are green; but tests only check the inputs we thought of. The way to send real production carts through both paths is a parallel run: you send the same request to both implementations, always return the old result to the customer and log the differences. GitHub published this idea in 2016 as the Scientist library for Ruby; it calls the old code the control and the new code the candidate, and there is a .NET port named Scientist as well. Our needs were small, so we wrote our own decorator:

// Parallel run: the legacy result is returned; the new one is only compared and logged.
public sealed class ParallelRunShippingCarrier(
    LegacyShippingCarrier legacy,
    HttpShippingCarrier candidate,
    ILogger<ParallelRunShippingCarrier> logger) : IShippingCarrier
{
    public async Task<decimal> GetRateAsync(string postalCode, decimal weightKg, CancellationToken cancellationToken = default)
    {
        decimal expected = await legacy.GetRateAsync(postalCode, weightKg, cancellationToken);
        try
        {
            decimal actual = await candidate.GetRateAsync(postalCode, weightKg, cancellationToken);
            if (actual != expected)
                logger.LogWarning("Rate mismatch for {PostalCode} {WeightKg}kg: legacy {Legacy}, new {New}",
                    postalCode, weightKg, expected, actual);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            // An error in the new path never reaches the customer.
            logger.LogWarning("New carrier failed for {PostalCode} {WeightKg}kg: {Error}", postalCode, weightKg, ex.Message);
        }
        return expected;
    }

    // Creating a shipment has a side effect: only the legacy path runs.
    public Task<string> CreateShipmentAsync(int orderId, string postalCode, decimal weightKg, CancellationToken cancellationToken = default) =>
        legacy.CreateShipmentAsync(orderId, postalCode, weightKg, cancellationToken);
}

The Shipping:Carrier configuration value chooses which implementation is used: Legacy, ParallelRun or Http; the default is Legacy. I started the web application with ParallelRun and sent five different carts; among them were a postal code missing its leading zero and a cart with only e-books. The five answers the customer saw were the same as before; two lines landed in the log:

warn: Bookstore.Shipping.Carrier.ParallelRunShippingCarrier[0]
      Rate mismatch for 6100 1.2kg: legacy 82.40, new 102.4
warn: Bookstore.Shipping.Carrier.ParallelRunShippingCarrier[0]
      New carrier failed for 35210 0kg: Response status code does not indicate success: 400 (Bad Request).

Neither finding was in any test. The first is old customer addresses that lost their leading zero when they were imported from Excel: 6100 is really 06100. The old XML service, it turns out, quietly padded four-digit postal codes with a zero; the v2 API doesn't, takes the code for region "61" and returns a different price. The second is carts with only e-books: the weight is 0, the old service accepts it and returns the base price, the new API wants at least 1 gram.

The second finding also raises a business question: should a customer who buys only e-books pay a shipping fee? The answer is most likely no, but that is a behavior change, and not a decision to make while replacing a client. So we fixed the new client to behave like the old one (pad the postal code to five digits, send at least 1 gram), added both cases to the contract tests and wrote the e-book question up for the Product Owner as a separate item. In a second parallel run with the same five carts the log stayed empty.

A parallel run has its costs too. Every rate query to the carrier doubles; if the carrier charges per request or applies a rate limit, that has to be part of the calculation. The response time also becomes the sum of two calls; making the calls concurrently with Task.WhenAll, or running the new path only for a percentage of requests, reduces it. The real danger is in operations with side effects: had we run CreateShipmentAsync on both paths, every order would have gotten two shipments. That is why a parallel run is used only for reads; for operations that write, the new path is tested only in a test environment or on a fake account. The service-level version of the same idea is covered in the Microservices series post "From Monolith to Microservices: Strangler Fig, Branch by Abstraction and the Distributed Monolith Trap".

Switch and Clean Up: Flip, Then Delete

When the log had stayed empty for a few days, the switch was a one-line configuration change: "Carrier": "Http". A plain configuration value was enough here, because the switch has a single key and a redeploy is enough to go back. If we had needed to turn it on per user group, roll back without a deploy, or turn it on by percentage, we would have needed a real feature flag; flag types and Microsoft.FeatureManagement are covered in the Solution Patterns series post "Designing Feature Flags in Code: Toggle Types, Microsoft.FeatureManagement and Flag Debt".

The last step of branch by abstraction is the step most often skipped: deleting the old one. The static client, the adapter, the parallel run decorator, the switch and the Shipping:Endpoint setting went in one commit:

$ git show --shortstat --format=%s HEAD
refactor: remove LegacyShippingClient, its adapter, the parallel run and the switch

 9 files changed, 1 insertion(+), 153 deletions(-)

The DI registration left behind is one line: services.AddHttpClient<IShippingCarrier, HttpShippingCarrier>(...). Should the abstraction itself stay? An interface introduced for a migration may be unnecessary once the migration is over; keeping an interface with a single implementation because it "might be needed" goes against YAGNI. In our case IShippingCarrier stays, because it now has a job that doesn't depend on the migration: it marks the boundary of a third-party system and can stand in for the carrier in service tests. Making this decision consciously and writing it in the commit message tells the next reader why the interface is there.

Running a Long Refactoring on Trunk

The whole job moved forward not on a separate branch but on the main branch, as thirteen small commits. Every commit compiles, the tests are green, and thanks to the default value of Shipping:Carrier, production behavior doesn't change until the switch:

$ git log --oneline
51c062b refactor: remove LegacyShippingClient, its adapter, the parallel run and the switch
d260de5 chore: switch Shipping:Carrier to Http
02227e2 fix: new carrier pads postal codes and sends at least 1 g, as the parallel run showed
750f6b9 chore: fake carrier host for local runs
c44e79d test: fake carrier pads four-digit postal codes on the XML endpoint like the real one
d1e7815 feat: ParallelRunShippingCarrier and Shipping:Carrier switch (default Legacy)
d21d20c feat: HttpShippingCarrier for the v2 JSON API, same contract tests (not wired)
1313257 test: turn characterization tests into a contract for IShippingCarrier
db9279c refactor: ReturnService and FulfillmentService go through IShippingCarrier
96edb6e refactor: CheckoutService quotes through IShippingCarrier
7bc07d8 refactor: resolve checkout, fulfillment and return services from DI
f50cc8a refactor: introduce IShippingCarrier with a legacy adapter (no callers yet)
fec13be legacy: LegacyShippingClient and its callers, with characterization tests

Had we done the same work on a three-week feature/new-shipping-client branch, every change to the shipping code on the main branch during that time would have hit us at merge time. The review would also have been one big diff instead of thirteen small ones. Branch by abstraction lets you keep unfinished work safely on the main branch: the new code is there, but it isn't wired. Short-lived branches and small merges are covered in the Git series post "Teamwork with Git: Branching Strategies, Rulesets and Conventional Commits".

Common Pitfalls

Skipping the revert in Mikado. Fixing the errors from the first attempt in place because "I know them anyway" leaves you with a broken working copy instead of a graph. An hour later you can't tell which change was made for which prerequisite, and there is no green commit to return to. Experiment code is written to be thrown away; the graph is what has value.

Implementing the graph from the root instead of the leaves. Doing the goal node first because "now I know what to do" means repeating the Mikado experiment itself. The order is always from the leaves to the root.

Making the intermediate abstraction permanent. LegacyShippingCarrier, the parallel run decorator and the switch are temporary scaffolding. Each one that isn't deleted becomes a layer that, two years later, nobody knows the reason for. Plan the removal of the switch and the old path as part of the migration and put it in the backlog.

Parallel runs on operations with side effects. Comparing two paths on reads is cheap; for shipments, payments or emails, running both paths means doing the operation twice. Try the new path for such operations only on a test account or in a dry-run mode that doesn't actually perform the operation.

Hiding a difference found by the parallel run as a "fix" in the new code. Some differences, like the e-book cart, reveal an odd behavior of the old code. Fixing the new client to behave like the old one is the job of refactoring; changing the odd behavior is a separate decision and a separate commit.

A Small Experiment

★☆☆ Pick a static client or helper in your codebase and write the goal as "this class is deleted". Try to implement the goal directly, count the errors the compiler gives, write the prerequisites on a piece of paper and revert with git restore .. How many prerequisites came up?

★★☆ Change this post's ParallelRunShippingCarrier to make the two calls concurrently with Task.WhenAll and add a setting that runs the new path for only 10 percent of requests. Measure the response time in both versions; does an error in the new path reach the customer?

★★★ If your team has a migration running on a long-lived branch, plan how to move it to the main branch with branch by abstraction: what is the abstraction, which caller moves first, where does the switch live, and for which operations is a parallel run safe? Draw the plan as a Mikado graph.

In this post we replaced a client without changing the contract to the outside world: the carrier supported both versions. In the next post the contract itself changes. While renaming the Book.Price field to ListPrice and moving DiscountRate to a separate table, we'll look at how to change the API's JSON field, the EF Core property, the database column and the Angular model together with expand-contract.