Branch by abstraction for large changes Jump to heading

Some changes look impossible to do in small steps: replace the ORM, swap the payment provider, migrate from one HTTP client to another across two hundred call sites. The instinct is a long-lived branch: do the whole replacement in isolation, then merge it in one go. That branch drifts from trunk for weeks, conflicts with everyone, and lands as a review nobody can meaningfully read. Branch by abstraction does the same replacement on trunk, in small, safe steps. First introduce an abstraction over the thing being replaced, and move callers onto it. Then build the new implementation behind the abstraction, switch over — gradually, behind a flag if needed — and finally delete the old implementation and, often, the abstraction. Every step merges to trunk and leaves it working. This page walks through the steps, within trunk-based development setup.

When to use this approach Jump to heading

  • A change is too large for one short-lived branch, and the alternative is a long-lived one.
  • You are replacing a dependency, an implementation or an integration with many call sites.
  • Trunk must stay releasable throughout, as described in keeping trunk green.
  • You want to be able to switch back quickly if the new implementation misbehaves.

Step 1 — Introduce the abstraction Jump to heading

Define an interface that captures what callers need from the component — not everything the old component can do. Implement it first with the old component, so nothing changes in behaviour.

# payments/gateway.py — the abstraction
from typing import Protocol

class PaymentGateway(Protocol):
    def charge(self, account_id: str, amount_minor: int, currency: str) -> str: ...
    def refund(self, charge_id: str, amount_minor: int) -> str: ...

class LegacyGateway:                         # wraps the old provider, unchanged behaviour
    def charge(self, account_id, amount_minor, currency):
        return legacy_sdk.create_charge(account_id, amount_minor / 100, currency)
    def refund(self, charge_id, amount_minor):
        return legacy_sdk.refund(charge_id, amount_minor / 100)

This is one small pull request, merged to trunk the same day.

The steps of branch by abstractionFirst an abstraction is introduced with the old implementation behind it. Callers are migrated onto the abstraction in small batches. The new implementation is built behind it and switched on gradually with a flag. Finally the old implementation and the flag are removed. Every step lands on trunk.Abstractionold impl behind itweek 1Move callersbatches of call sitesweeks 1–2New implbehind abstractionweeks 2–3Switchflag, gradualweek 4Remove old+ flag, + maybe abstractionweek 5every bar is a series of small merges to trunk, never a long branch Layers during a branch-by-abstraction migrationCallers talk only to the abstraction. The abstraction chooses an implementation at runtime from a flag. Behind it, the legacy and new implementations coexist until the switch is complete, after which the legacy one is deleted.callers never see which implementation runsCallersuse PaymentGateway onlyAbstractioninterface + factoryFlagpayments-new-providerImplementationsLegacyGateway | NewProviderGatewaythe flag makes the switch reversible in seconds

Step 2 — Move callers onto the abstraction in batches Jump to heading

Change call sites to use the abstraction instead of the old component directly, a few modules per pull request. Each batch is a mechanical, easily reviewed change with no behaviour difference.

# Track progress: remaining direct uses of the old SDK outside the abstraction
git grep -n 'legacy_sdk\.' -- 'src/**/*.py' ':!src/payments/gateway.py' | wc -l

Add a lint rule or CI check that forbids new direct uses, so the count only goes down while the migration is in progress.

# CI: no new direct imports of the old SDK
base=$(git merge-base origin/main HEAD)
git diff "$base" HEAD -- 'src/**/*.py' ':!src/payments/gateway.py' | grep -E '^\+.*legacy_sdk' && {
  echo "use PaymentGateway instead of legacy_sdk directly"; exit 1; } || true

Step 3 — Build the new implementation behind the abstraction Jump to heading

Implement the same interface with the new component. It can be merged incrementally — unused, tested on its own — because nothing calls it yet.

class NewProviderGateway:
    def charge(self, account_id, amount_minor, currency):
        return new_sdk.payments.create(account=account_id, amount=amount_minor, currency=currency).id
    def refund(self, charge_id, amount_minor):
        return new_sdk.refunds.create(payment=charge_id, amount=amount_minor).id

Contract tests that run the same scenarios against both implementations are the best evidence that the new one can replace the old.

import pytest
@pytest.mark.parametrize("gateway", [LegacyGateway(), NewProviderGateway()])
def test_charge_and_refund_roundtrip(gateway, sandbox_account):
    charge = gateway.charge(sandbox_account, 1250, "EUR")
    assert gateway.refund(charge, 1250)

Step 4 — Switch over gradually behind a flag Jump to heading

Choose the implementation at runtime with a flag, so the switch can be rolled out to a few accounts first and rolled back instantly.

def get_gateway(account_id: str) -> PaymentGateway:
    if flags.enabled("payments-new-provider", account=account_id):
        return NewProviderGateway()
    return LegacyGateway()
Long-lived branch against branch by abstractionA long-lived replacement branch drifts for weeks, conflicts with everyone, merges as one huge review and switches every user at once. Branch by abstraction merges small steps continuously, keeps trunk releasable, and switches users gradually with an instant way back.Long-lived branchBranch by abstractionintegrationonce, at the endcontinuousreviewone huge diffmany small onesswitch-overall at oncegradual, by flagrollbackrevert the mergeflip the flagthe extra cost is the abstraction itself — usually a few dozen lines

Flag rollout and cleanup are covered in feature flags vs feature branches for unfinished work.

Step 5 — Remove the old implementation and the flag Jump to heading

Once all traffic uses the new implementation and has done so long enough to be confident, delete the old implementation, the flag, and — if the abstraction no longer earns its keep — the abstraction itself. This step is the one most often forgotten; schedule it when the switch starts.

git rm src/payments/legacy_gateway.py
git grep -n 'payments-new-provider' && echo "flag references remain"

Retiring flags is covered in retiring feature flags after launch.

Step 6 — Track the migration openly Jump to heading

A replacement spread over weeks needs visible progress: remaining call sites, rollout percentage, removal date. A short checklist in the tracking issue, updated as pull requests merge, keeps everyone aligned.

printf 'Direct legacy uses: %s\n' "$(git grep -c 'legacy_sdk\.' -- 'src/**/*.py' | awk -F: '{s+=$2} END{print s+0}')"

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is the abstraction permanent? Jump to heading

Not necessarily. Sometimes it is worth keeping — it makes the next replacement easy and simplifies testing. Sometimes it is just scaffolding and should go with the old implementation.

Does this work for database migrations? Jump to heading

Yes, combined with expand–contract on the schema: add new columns or tables, write to both, move reads, stop writing to the old, then remove it. Each step is a small, deployable change.

How long should a branch-by-abstraction migration take? Jump to heading

As long as the work needs — weeks is normal — because trunk stays healthy throughout. The pressure to finish comes from carrying two implementations, not from a diverging branch.