Software Engineering · Refactoring

Branch by Abstraction and the Cost of Two Live Paths

Replacing a widely used component without a long-lived branch means running both implementations behind one interface. The interface, not the code, is what has to be designed.

Replacing a component that every part of a system calls is not a refactoring in the small sense. The old and new implementations cannot both be correct at the same moment if callers must choose between them, and a branch that holds both for weeks accumulates conflicts until merging it costs more than the rewrite. Branch by abstraction is the alternative: introduce an interface that both implementations satisfy, move callers onto the interface while the old implementation is still behind it, then swap the implementation and delete the old one.

The technique is often described as a way to avoid long-lived branches. That framing understates the difficulty. The real work is designing an interface that fits call sites written against a concrete implementation, and then living with two implementations that must both be correct for however long the migration takes.

Why the interface cannot be derived from the old type

The obvious move is to extract an interface from the existing class, method for method, and let the new implementation implement it. That produces an interface shaped by the old implementation’s accidents: methods that exist for internal reasons, parameters that reflect the old storage layout, return types that expose old data structures.

Callers then migrate onto an interface that encodes the assumptions of the code being replaced, and the new implementation must reproduce those assumptions or the callers break. The migration succeeds mechanically and fails architecturally.

The interface has to be designed from what callers need, which requires reading the call sites rather than the implementation. Three properties of each method decide its signature.

The first is whether any caller uses the full result or whether all callers project a subset. If every caller reads two fields of a returned structure, the interface returns those two fields, and the new implementation is free to compute them differently.

The second is whether any caller depends on an ordering or an error mode that is incidental. A method that throws on a missing key and a method that returns a null are different contracts, and the choice constrains the implementation.

The third is whether the call is synchronous because the caller needs the answer or because the old implementation happened to be synchronous. Converting a call to asynchronous is a signature change on every caller, so it belongs in the interface design, not in a later phase.

// Designed from call sites, not extracted from the old class.
public interface PriceSource {
    // Callers only ever need the total; the old type exposed a line-item list.
    Money totalFor(OrderId id, Instant asOf);
}

A narrower interface has a second effect that matters during the overlap: it is small enough that both implementations can satisfy it honestly, without either one growing adapter code whose only purpose is to bridge a mismatch.

Moving callers without breaking them

Callers migrate one at a time, and the interface must be usable by a caller before that caller has been changed. This is where the abstraction seam earns its name: the old implementation is wrapped in an adapter that implements the interface, and the wrapper is installed at the construction point.

The construction point is the hard part. In code where the old type is instantiated directly at a hundred sites, an interface does nothing until those sites are changed. Two approaches work. Move instantiation to a single factory and change the hundred sites to call the factory, which is a mechanical edit but a hundred-site edit; or add a default parameter or a global holder that returns the interface, which changes nothing at the sites but introduces a hidden dependency.

The first is more work and produces better code. The second is faster and produces a codebase where the implementation in use is not visible at the call site, which is the same complaint raised against link seams in Seams: The Places Where Behavior Can Be Substituted.

Routing between implementations

Once both implementations exist behind the interface, something selects between them. Three mechanisms, in increasing order of operational risk.

A compile-time choice, where the build decides. Zero runtime cost and no way to change behavior without a deploy. Useful for a library where the choice is genuinely fixed per consumer.

A configuration flag read at construction. The selection happens once per process, so the cost is one branch at startup and the behavior is stable for the process lifetime. This is the usual choice for a component swap.

A per-request flag, evaluated on every call. The cost is a lookup per call and the risk is that the two implementations now differ in observable ways that a request can detect. It also means the system has two live code paths at once, which doubles the state that must be correct under concurrency.

The per-request flag is the one that makes gradual rollout possible, and it is the one that most often causes an incident: a request that reads from the new path and writes through the old one, or a cache populated by one implementation and read by the other.

The cost of two live paths

Two implementations behind one interface is not a stable state. It is a state with a fixed cost per unit time, and the cost has several components.

Double correctness obligation. Every bug fixed in one path must be evaluated for the other. Every edge case discovered in one must be checked in the other. This is not a large per-bug cost, but it recurs for the whole duration and it is invisible in any plan.

Divergent behavior that no test compares. The two paths are exercised by different tests, or by the same tests with a flag flipped. If the tests assert on the interface’s contract, they cover both. If they assert on implementation details, they cover one and miss the other.

Data written by both. The most expensive component. If the two implementations write to the same store in different formats, the store now contains a mixture that neither implementation fully understands. Reading such a mixture requires either a compatibility layer or a backfill, which is the subject of Migrating Stored Data While Both Formats Are Live.

Attention. A reviewer seeing two implementations of one interface must decide, on every subsequent change, which one the change applies to. That decision is cheap individually and is a tax on every change touching the area.

The consequence for planning: the migration should be as short as the double-correctness obligation allows. A migration that runs for a year costs twelve months of duplicated maintenance, and that cost is usually larger than the benefit of avoiding a coordinated cutover.

Retiring the old path

The final phase is the one most often left undone. The old implementation stays in the tree, referenced by a flag that is set to the new value everywhere, and the flag’s default is never changed because someone is unsure whether the old path is still needed.

The retirement is a sequence with a verifiable end state:

Remove the per-request or per-configuration selection and construct the new implementation unconditionally. If this breaks something, the selection was load-bearing and the failure is informative. Delete the old implementation and its adapter. Delete the flag, and with it the tests that exercised the old path, which now assert nothing. Remove the interface if it has one implementation and no seam purpose; keeping it is the case where an abstraction outlives its justification.

Each step is small and each is checkable by the compiler. The compiler is what makes retirement safe: once the old implementation is deleted, any remaining reference is a build error rather than a runtime surprise. That is the argument for deleting rather than commenting out, and for deleting in the same change that removes the last caller.

When branch by abstraction is the wrong shape

The technique assumes a boundary that can be named, an interface that can be designed, and callers that can be moved one at a time. Three situations violate those assumptions.

When the component’s behavior is not expressible as a set of calls. A change to a storage engine’s page format has no interface to abstract over; the callers are the filesystem. The migration is a data migration, and the sequencing problem is different.

When the callers cannot be changed at all, typically because they are outside the organization. Then the interface must be a versioned protocol, and the migration is the expand-contract sequence described in Expand, Migrate, Contract: Changing an Interface Without a Flag Day.

When the old and new implementations must be compared against each other continuously rather than selected between. That is a different pattern: run both, discard one result, compare. It has its own costs, chiefly that the discarded path still consumes resources and still fails, and a failure in the discarded path must not fail the request.

References

  1. Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation — Humble and Farley, Addison-Wesley, 2010
  2. Patterns of Enterprise Application Architecture — Martin Fowler, Addison-Wesley, 2002
  3. Refactoring: Improving the Design of Existing Code, second edition — Martin Fowler, Addison-Wesley, 2018