diff.expert
TechniquesExercisesReference
© 2026 diff.expertAn Arbiter course
Home/Techniques/Branch by Abstraction
Technique 6

Branch by Abstraction

Introduce an interface, then swap what's behind it.

←PreviousExtract and DelegateNextParallel Implementation→

When to Use

You're replacing one implementation with another — a new database, a new service, a new API protocol — and want to do it incrementally without a big-bang cutover. The key insight is that by placing an abstraction boundary between consumers and the implementation, you can build the replacement behind the same contract and switch over without touching any consumer code.

You'll know this technique applies when you find yourself thinking "we need to swap out X for Y." That thought is your signal: the abstraction IS the first PR. The interface defines the contract that both the old and new implementations must satisfy, which means you can validate the new implementation in production long before any user actually sees it.

The Pattern

  • PR 1 — Extract the interface: Define an abstraction over the existing implementation. Wrap the current code as a class implementing the new interface. All call sites use the interface type. Zero behavior change.
  • PR 2 — Build the replacement: Write the new implementation behind the same interface. It ships to production but nothing uses it yet — dark code.
  • PR 3 — Add the switching mechanism: Introduce a factory function and a config variable that selects which implementation to instantiate. The default still points to the old implementation.
  • PR 4 — Switch over: Change the default. One line. This is the payoff.
  • PR 5 — Remove the old: Delete the original implementation and the factory's switching logic. Clean, direct construction.

Commit Sequence

How this looks in your git history:

Worked Example

The engineering team needs to migrate from REST API calls to GraphQL for loading product data. The current codebase has direct fetch() calls to the REST API scattered across multiple components — each component that loads products has its own inline fetch logic mixed with rendering code.

We'll introduce a ProductDataSource interface, build a GraphQLProductDataSource behind it, add a factory that selects the implementation via config, flip the config switch, and then remove all the REST code. At no point does any consumer change after PR 1.

1

PR 1: Extract ProductDataSource interface over existing REST calls

Define a ProductDataSource interface with the three operations consumers actually use — getProduct, listProducts, and searchProducts. Create RestProductDataSource implementing the interface, with the existing fetch logic moved verbatim inside it. All call sites are updated to receive a ProductDataSource via dependency injection rather than calling fetch directly.

This is a pure structural change — identical behavior. Every existing test passes without modification. What we've gained is the abstraction boundary: consumers now depend on the interface, not the fetch calls, which means we can swap what's behind it without touching a single component.

src/lib/products/data-source.ts
line numberline content
2

PR 2: Implement GraphQLProductDataSource (unused)

Create GraphQLProductDataSource implementing the same ProductDataSource interface using GraphQL queries. The class uses a shared gqlFetch helper, three typed query constants, and maps the GraphQL response shapes to the Product and ProductSearchResult types the interface requires.

Nothing calls this class yet — it ships to production as dark code. No users see any change. We ship it now to give ourselves the option to test it against staging, run it in a shadow mode, or route a small percentage of internal traffic to it before the switch.

src/lib/products/graphql-data-source.ts
line numberline content
3

PR 3: Add factory function with config-driven data source selection

Create src/lib/products/index.ts with a createProductDataSource() factory that reads the PRODUCT_DATA_SOURCE environment variable and returns the matching implementation. The singleton productDataSource is exported from this module — all consumers import from here, not directly from either implementation class.

The default is still 'rest', so behavior is unchanged for all users. The wiring is now in place for a one-line switch in the next PR. Consumers don't change at all — they already depend on the ProductDataSource interface, not the concrete class.

src/lib/products/index.ts
line numberline content
4

PR 4: Switch to GraphQL data source

Change the default in createProductDataSource() from 'rest' to 'graphql'. This is the entire PR.

This is the payoff of Branch by Abstraction: the migration that required four PRs to prepare is executed with a single character change. No consumer code changes, no risk of regressions — just a config default flip. The old REST implementation is still present in case a quick rollback is needed, but the GraphQL path now serves all traffic.

src/lib/products/index.ts
line numberline content
5

PR 5: Remove RestProductDataSource and factory switching logic

Delete RestProductDataSource from data-source.ts, remove the factory function and the DataSourceKind type from index.ts, and replace the singleton with a direct new GraphQLProductDataSource(). The diff here shows index.ts shrinking from 20 lines to 4 — the full cleanup also removes the RestProductDataSource class from data-source.ts.

Clean final state: no switching logic, no dead code, no environment variable that future engineers will wonder about. The abstraction layer served its purpose and the interface remains, now implemented exclusively by GraphQL.

src/lib/products/index.ts
line numberline content

Common Mistakes

Making the abstraction too leaky. The interface should be expressed in terms of the domain — products, not HTTP. If your ProductDataSource interface has methods like buildRestUrl() or formatGraphQLQuery(), you've leaked an implementation detail into the contract. Both implementations would then need to satisfy an interface shaped for one of them. Define the interface from the consumer's perspective: what operations do components need? Those become the interface methods.

Skipping the validation phase between "build new" and "remove old." The factory in PR 3 exists precisely to give you a period where both implementations are in production code, even if only one is serving traffic. Use this window: run smoke tests against the GraphQL implementation in staging, shadow-traffic it against production, or let it serve a small percentage of requests before committing to PR 5. Going straight from PR 2 to removing the REST code skips the confidence-building step that makes the technique safe.

Extracting an interface that's too broad. Don't model the interface on everything the REST API can do — model it on what your consumers actually use. If no component ever calls deleteProduct(), the interface should not have it. Broad interfaces create unnecessary implementation burden for the new data source and become a maintenance liability as the codebase evolves.

←PreviousExtract and DelegateNextParallel Implementation→
← All techniques