Branch by Abstraction Pattern

Safely swap dependencies in production without breaking anything

The Friday 5pm Deploy Problem

You need to swap out a critical library.

  • Your ORM is deprecated
  • A new payment provider has better rates
  • That logging library has a security vulnerability

Big bang replacement = high risk.

The Dependency Swap Challenge

Changing a deeply embedded dependency touches hundreds of files.

Traditional approach:

  1. Create a long-lived feature branch
  2. Swap everything at once
  3. Merge after weeks/months
  4. Cross your fingers on deployment day

Success rate? Not great.

What is Branch by Abstraction?

A technique for making large-scale changes to a codebase gradually while maintaining continuous integration.

Core idea: Create an abstraction layer that lets old and new implementations coexist.

  • Both implementations live in trunk
  • No long-lived branches
  • Switch incrementally with feature flags
  • Safe rollback at any point

Coined by Paul Hammant in 2007, popularized by Martin Fowler.

How It Works: The Core Mechanism

Instead of directly calling a dependency, call through an abstraction:

// Before: Direct dependency
import { OldLogger } from 'old-logging-lib'

class OrderService {
  private logger = new OldLogger()

  processOrder(order: Order) {
    this.logger.log('Processing order')
  }
}

Problem: Tightly coupled to OldLogger throughout codebase.

Step 1: Create the Abstraction

Define an interface that both implementations can satisfy:

// New abstraction layer
interface Logger {
  log(message: string): void
  error(message: string, error?: Error): void
}

// Adapter for existing library
class OldLoggerAdapter implements Logger {
  private oldLogger = new OldLogger()

  log(message: string) {
    this.oldLogger.log(message)
  }

  error(message: string, error?: Error) {
    this.oldLogger.error(message)
  }
}

Step 2: Migrate Clients to Abstraction

Update code to use the abstraction, not the concrete class:

class OrderService {
  constructor(private logger: Logger) {}

  processOrder(order: Order) {
    this.logger.log('Processing order')
  }
}

// Wire up old implementation
const logger = new OldLoggerAdapter()
const service = new OrderService(logger)

Deploy this first. Nothing changes functionally. System still works.

Step 3: Build New Implementation

Create new implementation behind the same interface:

// New modern logging library
class NewLoggerAdapter implements Logger {
  private newLogger = new ModernLogger({
    format: 'json',
    level: 'info'
  })

  log(message: string) {
    this.newLogger.info(message)
  }

  error(message: string, error?: Error) {
    this.newLogger.error(message, { error })
  }
}

Both implementations exist in the codebase simultaneously.

Step 4: Gradual Switchover with Feature Flags

Use feature flags to control which implementation runs:

class LoggerFactory {
  create(): Logger {
    const useNewLogger = featureFlags.isEnabled(
      'use-new-logger',
      { userId: currentUser.id }
    )

    if (useNewLogger) {
      return new NewLoggerAdapter()
    }

    return new OldLoggerAdapter()
  }
}

Roll out gradually: 1% → 10% → 50% → 100%

Step 5: Remove Old Implementation

Once 100% of traffic uses new implementation:

// Delete old adapter
// Delete old library dependency
// Optionally: Remove abstraction if no longer needed

class OrderService {
  constructor(private logger: ModernLogger) {}

  processOrder(order: Order) {
    this.logger.info('Processing order')
  }
}

The abstraction served its purpose. Clean up is optional.

Real Example: Migrating ORMs

Scenario: Moving from TypeORM to Prisma in a production app.

Step 1: Define repository abstraction

interface UserRepository {
  findById(id: string): Promise<User | null>
  save(user: User): Promise<void>
  findByEmail(email: string): Promise<User | null>
}

Step 2: Wrap existing TypeORM code

class TypeORMUserRepository implements UserRepository {
  async findById(id: string) {
    return this.typeorm.getRepository(User).findOne(id)
  }
  // ... other methods
}

Real Example: ORM Migration (cont.)

Step 3: Build new Prisma implementation

class PrismaUserRepository implements UserRepository {
  async findById(id: string) {
    return this.prisma.user.findUnique({ where: { id } })
  }
  // ... other methods
}

Step 4: Use feature flag to switch

const repo = featureFlags.isEnabled('use-prisma')
  ? new PrismaUserRepository()
  : new TypeORMUserRepository()

ThoughtWorks used this exact pattern migrating Go CI from iBatis to Hibernate over 12 months.

Best Practices

Do:

  • Keep the abstraction thin and focused
  • Use feature flags for gradual rollout
  • Monitor both implementations during migration
  • Test both code paths until old one is removed
  • Commit frequently to trunk (this is the point!)

Don't:

  • Make the abstraction too complex
  • Let the migration drag on indefinitely
  • Skip testing the abstraction layer itself
  • Leave abstraction in place permanently (unless it adds value)

Common Pitfalls

Abstraction becomes too generic

  • Trying to fit incompatible systems creates bloated interfaces
  • Keep it focused on your specific use case

Migration takes too long

  • Set deadlines: 3-6 months is ideal
  • The abstraction layer adds complexity

Poor monitoring during switchover

  • Both implementations must have observability
  • Know immediately if new version has issues

Leaving zombie abstractions

  • Clean up after yourself
  • Every abstraction adds indirection

When to Use Branch by Abstraction

Perfect for:

  • Swapping ORMs, loggers, HTTP clients
  • Framework migrations (React to Vue, Express to Fastify)
  • Database engine changes (MySQL to PostgreSQL)
  • Library upgrades with breaking changes
  • When you need continuous integration (no long branches)

Not ideal for:

  • Small, isolated dependencies (just swap them)
  • External system integration (use Strangler Fig instead)
  • When the abstraction is more complex than the migration
  • Greenfield projects (no legacy to replace)

Branch by Abstraction vs Strangler Fig

| Branch by Abstraction | Strangler Fig | | ------------------------------- | ---------------------------------- | | Internal dependencies | External system replacement | | Swap libraries/frameworks | Migrate to microservices | | Single codebase | Multiple services/systems | | Weeks to months | Months to years | | Programming language abstraction| Routing/proxy infrastructure | | Same deployment artifact | Separate deployments |

Example: Use Branch by Abstraction to swap your ORM. Use Strangler Fig to break apart your monolith.

Key Takeaways

1. Abstraction Enables Coexistence

Old and new implementations live side-by-side in trunk. No long-lived branches.

2. Gradual Rollout Reduces Risk

Feature flags let you test in production at any scale. Instant rollback if issues arise.

3. Temporary Complexity for Safety

The abstraction adds indirection, but eliminates "big bang" deployment risk.

Branch by Abstraction trades temporary complexity for continuous delivery.

Your First Branch by Abstraction

Week 1: Identify a dependency causing pain (deprecated library, security issue)

Week 2: Design thin abstraction interface that fits both old and new

Week 3: Refactor existing code to use abstraction (deploy!)

Week 4: Build new implementation behind abstraction (deploy!)

Week 5-6: Gradually roll out with feature flags, monitor closely

Week 7: Remove old implementation and clean up

Resources: martinfowler.com/bliki/BranchByAbstraction.html

Next in the Series

You've learned:

  • Legacy modernization fundamentals
  • Strangler Fig (external system migration)
  • Branch by Abstraction (internal dependency swaps)

Coming next:

  • Parallel Run pattern
  • Feature flags deep dive
  • Real-world migration case studies

Subscribe for the complete patterns library for safe refactoring.

1 / 0
Branch by Abstraction Pattern