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.
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() 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.
Related Jump to heading
- Trunk-Based Development Setup — the parent topic.
- Catching Cross-Package Breaking Changes — expand–contract for shared libraries.
- Coordinating a Large Refactor Without Conflict Storms — the conflict side of big changes.
- Merge vs Rebase for Long-Running Branches — if a long branch is truly unavoidable.