Branch by Abstraction

Replace an existing implementation behind a stable interface, one small commit at a time, without a long-lived branch.

Phase 1 - Foundations | Scope: Team

Branch by abstraction lets you replace an internal implementation, algorithm, or library on trunk, without a long-lived branch and without disrupting the code that already depends on it.

What Is Branch by Abstraction?

Branch by abstraction introduces an interface over an existing implementation, redirects callers to that interface, then builds and switches in a new implementation behind it, all as small commits on trunk. The “branching” happens in the abstraction layer, not in version control.

The technique gets its name because it replaces a source-control branch with a branch in the code itself: an interface with two implementations, one of which is live.

What Branch by Abstraction Is Not

  • It is not a long-lived feature branch with an interface added to justify it. If the work still takes weeks on a branch, the abstraction hasn’t replaced anything.
  • It is not the strangler fig pattern. Branch by abstraction swaps an implementation behind an in-process interface. Strangler fig replaces a whole subsystem or service by routing traffic to it at a system boundary. Use branch by abstraction inside a codebase you own; use strangler fig when the thing being replaced is bigger than one component.
  • It is not a permanent abstraction layer. Once the swap is complete, remove the old implementation, and remove the interface too if nothing else needs it.

What Branch by Abstraction Improves

ProblemHow Branch by Abstraction Helps
Large refactors force a long-lived branchThe refactor happens in small commits on trunk, behind an interface
Fear of breaking existing callers during a rewriteCallers depend on the interface, not the implementation, so the swap is invisible to them
“Big bang” cutover riskThe switch is a single dependency-injection change, easy to revert
Dead code left behind after a migrationThe interface makes the old implementation easy to find and delete

Making the Swap

Step 1: Abstract

Introduce an interface over the existing code and redirect every caller to it. This is a zero-behavior-change commit: the interface wraps the current implementation and nothing else changes.

Step 1: introduce the interface over the existing implementation
class AuthService {
  authenticate(credentials) {
    // existing implementation, moved behind the interface unchanged
  }
}

// callers now depend on AuthService, not the concrete legacy class
const auth = new AuthService();

Step 2: Implement

Build the new implementation alongside the old one, as its own class. Commit and deploy the new class in small pieces; it isn’t wired to any caller yet, so it carries the same zero risk as dark code.

Step 2: build the new implementation alongside the old one
class LegacyAuthService {
  authenticate(credentials) {
    // existing messy implementation, untouched
  }
}

class ModernAuthService {
  authenticate(credentials) {
    // new implementation, built and tested incrementally
  }
}

Step 3: Switch

Change the dependency injection or factory binding to instantiate the new implementation instead of the old one. This is the entire cutover: one line, one commit, easy to revert.

Step 3: switch the binding to the new implementation
// container.js
container.register('AuthService', ModernAuthService); // was LegacyAuthService

If you need to de-risk the switch further, or need confidence the two implementations produce identical results first, run them side by side with a parallel run before flipping the binding.

Step 4: Prune

Delete the legacy implementation. Delete the interface too if only one implementation remains and nothing else depends on the abstraction.

Step 4: delete the legacy implementation
class AuthService {
  authenticate(credentials) {
    // just the modern implementation now
  }
}

Cleanup here is a straightforward deletion of a class. There is no scattered if/else logic to search for, because the old and new implementations were never in the same function.

Key Pitfalls

1. “We built the new implementation and the interface in the same commit”

This makes the abstraction hard to review on its own merits, and it removes the option to ship the interface as a safe, standalone step. Extract the interface first, verify it changes nothing, then start on the new implementation.

2. “We left the old implementation in place after the switch”

The switch commit is not the finish line. If the old class is still in the codebase a month later, delete it. An unused implementation behind a working interface is exactly the kind of dead weight branch by abstraction is supposed to avoid.

3. “We used branch by abstraction to replace a whole service”

If the replacement spans multiple components, teams, or a system boundary, that’s a strangler fig problem, not an in-process interface swap.

Measuring Success

MetricTargetWhy It Matters
Time from abstraction to switchDays to a few weeksConfirms the technique is replacing a branch, not becoming one
Legacy implementations still in the codebase after switchZero after the agreed cleanup windowConfirms pruning actually happens
Commits per swapMany small commits, no single large diffConfirms the refactor stayed on trunk in small pieces

Next Step

If you need to prove the new implementation matches production behavior before switching the binding, use Parallel Run.