Safely swap dependencies in production without breaking anything
You need to swap out a critical library.
Big bang replacement = high risk.
Changing a deeply embedded dependency touches hundreds of files.
Traditional approach:
Success rate? Not great.
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.
Coined by Paul Hammant in 2007, popularized by Martin Fowler.
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.
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)
}
}
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.
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.
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%
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.
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
}
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.
Do:
Don't:
Abstraction becomes too generic
Migration takes too long
Poor monitoring during switchover
Leaving zombie abstractions
Perfect for:
Not ideal for:
| 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.
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.
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
You've learned:
Coming next:
Subscribe for the complete patterns library for safe refactoring.