Branch by Abstraction
Branch by Abstraction is a powerful technique for making large-scale changes to production code without using long-lived feature branches. It allows you to swap out major dependencies—databases, APIs, libraries, entire subsystems—while keeping your code continuously deployable and your team working from a single mainline branch.
This pattern is essential for teams practicing Continuous Integration and Continuous Delivery, where the goal is to keep the main branch always in a deployable state.
Video Summary
The video presentation walks through Branch by Abstraction in detail:
- The problems with long-lived feature branches for major changes
- How abstraction enables safe dependency swapping
- Step-by-step implementation process with concrete examples
- Managing the transitional state where both implementations coexist
- Cleaning up after the migration is complete
- Real-world scenarios where this pattern excels
Key Concepts
1. The Problem: Large Changes on Feature Branches
Traditional approach to major changes:
- Create long-lived feature branch
- Spend weeks/months rewriting component
- Try to merge back to main
- Deal with massive merge conflicts
- Struggle with integration issues
Why this fails:
- Merge conflicts grow exponentially with time
- Integration issues discovered late
- Can't easily release other features
- Team is divided between branches
- Code review becomes nearly impossible
2. The Solution: Abstract, Migrate, Clean Up
Branch by Abstraction follows a three-phase approach:
Phase 1: Create Abstraction
// Before: Direct dependency on old implementation
class OrderService {
constructor(private mysqlDatabase: MySQLDatabase) {}
async getOrder(id: string) {
return this.mysqlDatabase.query('SELECT * FROM orders WHERE id = ?', [id]);
}
}
// After: Abstraction introduced
interface OrderRepository {
getOrder(id: string): Promise<Order>;
}
class OrderService {
constructor(private orderRepo: OrderRepository) {}
async getOrder(id: string) {
return this.orderRepo.getOrder(id);
}
}
// Existing implementation wrapped in abstraction
class MySQLOrderRepository implements OrderRepository {
constructor(private db: MySQLDatabase) {}
async getOrder(id: string) {
const row = await this.db.query('SELECT * FROM orders WHERE id = ?', [id]);
return this.mapRowToOrder(row);
}
}
Phase 2: Build New Implementation
// New implementation behind same interface
class PostgresOrderRepository implements OrderRepository {
constructor(private db: PostgresDatabase) {}
async getOrder(id: string) {
const row = await this.db.query('SELECT * FROM orders WHERE id = $1', [id]);
return this.mapRowToOrder(row);
}
}
// Switch between implementations with config
function createOrderRepository(config: Config): OrderRepository {
if (config.usePostgres) {
return new PostgresOrderRepository(postgresDb);
}
return new MySQLOrderRepository(mysqlDb);
}
Phase 3: Remove Old Implementation
// After migration complete: remove abstraction if no longer needed
class OrderService {
constructor(private db: PostgresDatabase) {}
async getOrder(id: string) {
const row = await this.db.query('SELECT * FROM orders WHERE id = $1', [id]);
return this.mapRowToOrder(row);
}
}
3. Continuous Integration Friendly
All changes happen on the main branch:
- Abstraction added in small commits
- New implementation built incrementally
- Feature flag controls which implementation runs
- Old implementation removed after migration
- Main branch is always deployable
4. Gradual Migration
Like Strangler Fig, migration happens gradually:
class RoutingOrderRepository implements OrderRepository {
constructor(
private mysql: MySQLOrderRepository,
private postgres: PostgresOrderRepository,
private featureFlags: FeatureFlags
) {}
async getOrder(id: string): Promise<Order> {
// Gradual rollout by user ID
if (this.featureFlags.isEnabledForUser('postgres-orders', id)) {
return this.postgres.getOrder(id);
}
return this.mysql.getOrder(id);
}
}
Real-World Applications
Example 1: Switching Payment Processors
Scenario: Migrating from Stripe to Adyen for lower fees.
// Step 1: Create abstraction
interface PaymentProcessor {
charge(amount: number, token: string): Promise<ChargeResult>;
refund(chargeId: string): Promise<RefundResult>;
}
// Step 2: Wrap existing Stripe code
class StripePaymentProcessor implements PaymentProcessor {
async charge(amount: number, token: string) {
const charge = await stripe.charges.create({
amount,
currency: 'usd',
source: token,
});
return { id: charge.id, status: charge.status };
}
async refund(chargeId: string) {
const refund = await stripe.refunds.create({ charge: chargeId });
return { id: refund.id, status: refund.status };
}
}
// Step 3: Build new Adyen implementation
class AdyenPaymentProcessor implements PaymentProcessor {
async charge(amount: number, token: string) {
const payment = await adyen.payments.create({
amount: { value: amount, currency: 'USD' },
paymentMethod: { type: 'scheme', encryptedCardNumber: token },
});
return { id: payment.pspReference, status: payment.resultCode };
}
async refund(chargeId: string) {
const modification = await adyen.modifications.refund({
originalReference: chargeId,
});
return { id: modification.pspReference, status: modification.response };
}
}
// Step 4: Route based on feature flag
class ConfigurablePaymentProcessor implements PaymentProcessor {
private processor: PaymentProcessor;
constructor(config: Config) {
this.processor = config.useAdyen
? new AdyenPaymentProcessor()
: new StripePaymentProcessor();
}
charge(amount: number, token: string) {
return this.processor.charge(amount, token);
}
refund(chargeId: string) {
return this.processor.refund(chargeId);
}
}
Migration path:
- Deploy abstraction with Stripe implementation (no behavior change)
- Add Adyen implementation behind feature flag (disabled)
- Test Adyen with internal accounts
- Roll out to 5% of transactions
- Increase to 100% over several weeks
- Remove Stripe code and simplify abstraction
Example 2: Replacing Logging Library
Scenario: Migrating from Winston to Pino for better performance.
// Abstraction
interface Logger {
info(message: string, meta?: object): void;
error(message: string, error?: Error): void;
warn(message: string, meta?: object): void;
}
// Old implementation
class WinstonLogger implements Logger {
info(message: string, meta?: object) {
winston.info(message, meta);
}
error(message: string, error?: Error) {
winston.error(message, { error });
}
warn(message: string, meta?: object) {
winston.warn(message, meta);
}
}
// New implementation
class PinoLogger implements Logger {
info(message: string, meta?: object) {
pino.info(meta, message);
}
error(message: string, error?: Error) {
pino.error({ err: error }, message);
}
warn(message: string, meta?: object) {
pino.warn(meta, message);
}
}
// Factory with feature flag
function createLogger(): Logger {
if (process.env.USE_PINO === 'true') {
return new PinoLogger();
}
return new WinstonLogger();
}
Example 3: API Client Migration
Scenario: Updating from REST API v1 to GraphQL.
// Abstraction over data fetching
interface UserRepository {
getUserById(id: string): Promise<User>;
getUsersByTeam(teamId: string): Promise<User[]>;
}
// Old REST implementation
class RestUserRepository implements UserRepository {
async getUserById(id: string) {
const response = await fetch(`/api/v1/users/${id}`);
return response.json();
}
async getUsersByTeam(teamId: string) {
const response = await fetch(`/api/v1/teams/${teamId}/users`);
return response.json();
}
}
// New GraphQL implementation
class GraphQLUserRepository implements UserRepository {
async getUserById(id: string) {
const { data } = await graphqlClient.query({
query: gql`
query GetUser($id: ID!) {
user(id: $id) {
id name email team { id name }
}
}
`,
variables: { id },
});
return data.user;
}
async getUsersByTeam(teamId: string) {
const { data } = await graphqlClient.query({
query: gql`
query GetTeamUsers($teamId: ID!) {
team(id: $teamId) {
users { id name email }
}
}
`,
variables: { teamId },
});
return data.team.users;
}
}
Common Pitfalls
1. Abstraction Too Coupled to Old Implementation
Problem: The abstraction mirrors the old implementation too closely.
// Bad: Abstraction tied to SQL
interface UserRepository {
query(sql: string, params: any[]): Promise<any>;
}
// Good: Abstraction represents domain operations
interface UserRepository {
getUserById(id: string): Promise<User>;
saveUser(user: User): Promise<void>;
}
Solution: Design abstractions around your domain needs, not implementation details.
2. Leaving Abstractions Permanent
Problem: The abstraction was temporary but never gets removed.
Why it fails: Unnecessary abstractions add complexity and cognitive load.
Solution: Clean up after migration. If you only have one implementation, consider removing the abstraction (unless you anticipate another change).
3. Incomplete Abstraction
Problem: Abstraction covers 90% of use cases, forcing some code to bypass it.
// Incomplete abstraction
interface Cache {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
}
// Some code needs TTL, bypasses abstraction
const value = await redis.setex('key', 3600, 'value'); // Oops!
Solution: Ensure abstraction covers all current use cases before switching implementations.
4. Parallel Implementations Drift
Problem: While both implementations exist, only new one gets updates.
Why it fails: If you need to rollback, old implementation is now buggy.
Solution: Keep both implementations in sync until migration is complete and old one is removed.
Key Takeaways
- Branch by Abstraction enables large-scale changes without long-lived feature branches
- The pattern has three phases: abstract, migrate, clean up
- Abstractions should represent domain concepts, not implementation details
- All work happens on the main branch, keeping code continuously deployable
- Feature flags enable gradual migration and easy rollback
- Clean up temporary abstractions after migration to avoid accumulating complexity
- This technique is essential for teams practicing Continuous Integration
Further Reading
- Branch by Abstraction - Martin Fowler's article introducing the pattern
- Trunk-Based Development - The development style that makes this pattern necessary
- Feature Toggles - Managing feature flags effectively
- Working Effectively with Legacy Code - Chapter on "Seam Models" relates to this pattern