Appearance
Working with Legacy Code
What is Legacy Code?
In Michael Feathers' foundational book "Working Effectively with Legacy Code," he defines legacy code as code without tests. While this definition captures an important quality attribute, at Synapse we extend this definition to be more pragmatic:
Legacy code is code that doesn't conform to our current standards.
This broader definition acknowledges a fundamental truth: standards evolve faster than our ability to update existing systems. A codebase written two years ago using our best practices at the time may now be "legacy" because our understanding has deepened, our tools have improved, or our architectural patterns have matured.
This isn't a failure—it's the natural consequence of growth. The question isn't whether we'll have legacy code, but rather: What do we do about it?
The Challenge
The challenge of legacy code manifests in several ways:
System Inertia
- Large codebases resist change
- Dependencies create coupling that makes isolated improvements difficult
- Technical debt accumulates faster than it can be paid down
- Teams lack time to stop and "do it right"
The Big Rewrite Temptation
The instinct when facing a legacy system is often to start over. However, as Sam Newman notes in "Monolith to Microservices":
"If you do a big-bang rewrite, the only thing you're guaranteed of is a big bang."
Big rewrites fail because they:
- Stop delivering business value during the rewrite
- Underestimate the complexity and edge cases in the existing system
- Often recreate the same problems in the new system
- Require maintaining the old system in parallel
- Risk catastrophic failure if something goes wrong
The Pragmatic Reality
We need approaches that allow us to:
- Continue delivering business value
- Incrementally improve the system
- Manage risk through small, reversible changes
- Transform both technology and organizational practices
As the ThoughtWorks legacy displacement patterns emphasize: technology is at most only 50% of the legacy problem. Ways of working, organization structure, and leadership are equally important.
Foundational Principles
From "Working Effectively with Legacy Code"
Michael Feathers provides essential techniques for making changes safely in untested code:
Characterization Tests
When you don't understand what code does, write tests that characterize its current behavior (even if that behavior is wrong). These tests:
- Document what the system actually does
- Provide a safety net for refactoring
- Help identify behavior that's intentional vs. accidental
- Create confidence that changes don't break existing functionality
Finding Seams
A seam is a place where you can alter behavior without editing code in that place. Finding seams allows you to:
- Break dependencies to make code testable
- Insert test doubles or monitoring
- Gradually replace implementations
- Create boundaries for extraction
Common seams include:
- Interface boundaries
- Dependency injection points
- Event handlers
- HTTP endpoints
- Database queries
Breaking Dependencies
Legacy code often has dependencies that prevent testing. Techniques include:
- Extract Interface to create abstraction points
- Parameterize Constructor/Method to inject dependencies
- Extract and Override to isolate difficult-to-test behavior
- Introduce Static Setter for global dependencies (temporarily)
The key is making small, safe refactorings that enable testing, then using tests to enable larger changes.
From "Legacy Displacement Patterns"
The ThoughtWorks patterns (documented by Ian Cartwright, Rob Horn, and James Lewis) emphasize four interconnected activities for successful legacy modernization:
1. Understand Desired Outcomes
Before starting, explicitly define what success means:
- What business capabilities are we improving?
- What technical constraints are we removing?
- How will we measure success?
Use techniques like:
- Event Storming - Collaborative mapping of business processes
- Business Capability Mapping - How systems support organizational functions
- Value Stream Mapping - How users accomplish work
2. Break Problems into Smaller Parts
Identify seams in both architecture and business processes:
- Find natural boundaries in the system
- Look for stable interfaces
- Identify components that change together
- Separate what needs to change from what doesn't
3. Deliver Incrementally
Deploy pieces independently with managed risk:
- Small changes reduce risk
- Frequent deployments build confidence
- Parallel operation allows validation
- Rollback capability provides safety
4. Transform Organizational Practices
Align culture, structure, and ways of working with new approaches:
- Build team capability to work incrementally
- Create feedback loops for continuous improvement
- Establish new standards as living practices
- Empower teams to make local improvements
Incremental Modernization Strategies
Decomposition Patterns
These patterns come primarily from Sam Newman's "Monolith to Microservices," but they're valuable even when staying within a monolith. The goal is creating modularity and reducing coupling, regardless of deployment boundaries.
Strangler Fig Pattern
Gradually wrap and replace functionality by:
- Identify - Parts of the system to migrate
- Implement - New functionality (in a module, service, or microservice)
- Reroute - Calls from old to new implementation
Key insight: Separate deployment from release. Deploy the new code, but don't route traffic until it's validated in production. Use feature flags or routing logic to control the cutover.
Example within a monolith:
typescript
// Old implementation scattered across multiple files
class UserService {
async createUser(data) {
// 500 lines of tangled logic
}
}
// Step 1: Create new module with clean boundaries
// src/modules/user-management/public/user.service.ts
// Step 2: Route through adapter that delegates to new or old
class UserServiceAdapter {
constructor(
private newUserService: NewUserService,
private legacyUserService: LegacyUserService,
private featureFlags: FeatureFlags
) {}
async createUser(data) {
if (this.featureFlags.isEnabled('new-user-creation')) {
return this.newUserService.createUser(data);
}
return this.legacyUserService.createUser(data);
}
}
// Step 3: Gradually route more traffic, then remove old codeBranch by Abstraction
Make changes safely while keeping the system running:
- Create abstraction for functionality to replace
- Update clients to use the abstraction
- Create new implementation behind the abstraction
- Switch abstraction to new implementation (gradually or all at once)
- Remove old implementation
Example within a monolith:
typescript
// Before: Direct database access scattered everywhere
await db.query('SELECT * FROM users WHERE id = ?', [userId]);
// Step 1: Create abstraction
interface UserRepository {
findById(id: string): Promise<User>;
}
// Step 2: Implement old behavior
class LegacyUserRepository implements UserRepository {
async findById(id: string) {
return db.query('SELECT * FROM users WHERE id = ?', [id]);
}
}
// Step 3: Update all callers to use abstraction
constructor(private userRepo: UserRepository) {}
await this.userRepo.findById(userId);
// Step 4: Implement new behavior
class TypeORMUserRepository implements UserRepository {
async findById(id: string) {
return this.userRepository.findOne({ where: { id } });
}
}
// Step 5: Switch implementation through DI
// Step 6: Remove legacy implementationParallel Run
Run old and new implementations simultaneously to validate behavior:
- Route requests to both implementations
- Use one as source of truth (initially the legacy system)
- Compare results and log differences
- Fix discrepancies in new implementation
- Switch source of truth to new implementation
- Remove old implementation
Critical for:
- Complex business logic with edge cases
- Data migration validation
- High-risk replacements
Change Data Capture
When you can't modify calling code, react to data changes:
- Database triggers - Stored procedures that fire on changes
- Transaction log polling - Monitor database logs (e.g., Debezium)
- Batch delta copiers - Periodic scans for changes
Use when:
- You can't modify the monolith's code
- Data synchronization is needed
- Building read models or projections
Displacement Patterns
These patterns from the ThoughtWorks article help route around legacy systems:
Event Interception
Intercept and reroute events before they reach legacy code:
User → API Gateway → New Service ──┐
↓ ↓
Legacy System ← [fallback]Example:
- New endpoint in your API layer routes to new module
- Old endpoint remains but is marked deprecated
- Gradually migrate clients to new endpoint
Legacy Mimic
New system mimics old system's interface so legacy code remains unaware:
Example:
typescript
// Legacy code expects this interface
interface PaymentGateway {
charge(amount: number, card: string): Promise<TransactionId>;
}
// New implementation mimics old interface
class StripePaymentGateway implements PaymentGateway {
async charge(amount: number, card: string) {
// New implementation with Stripe
// Returns same shape as old system
}
}Divert the Flow
Redirect user flows away from legacy system at business process level:
Example:
- New customer onboarding flow uses new modules
- Existing customers continue with legacy flow
- Gradually migrate existing customers
Transitional Architecture
Create temporary infrastructure specifically for the migration:
- Adapter layers that will be removed
- Data synchronization that's temporary
- Feature flags for gradual rollout
- Routing logic during transition
Important: Plan for removal from the start. Mark transitional code clearly.
Critical Aggregator
Combine data from multiple sources for critical decisions:
typescript
class UnifiedReportingService {
async getCustomerMetrics(customerId: string) {
const [legacy, newSystem] = await Promise.all([
this.legacyAPI.getMetrics(customerId),
this.newAPI.getMetrics(customerId)
]);
return this.merge(legacy, newSystem);
}
}Database Patterns
Database View
Provide read-only projections that hide implementation:
sql
CREATE VIEW customer_summary AS
SELECT
c.id,
c.name,
COUNT(o.id) as order_count,
SUM(o.total) as lifetime_value
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;Database Wrapping Service
Hide database complexity behind an API:
typescript
class CustomerService {
async getCustomerSummary(id: string) {
// Hides complex joins, multiple tables, etc.
// Returns clean domain model
}
}Aggregate Exposing Monolith
Expose aggregate operations (not raw data):
typescript
// Don't expose: getOrders(), getOrderItems(), getPayments()
// Instead expose:
class OrderService {
async completeOrder(orderId: string): Promise<void>;
async cancelOrder(orderId: string): Promise<void>;
async getOrderStatus(orderId: string): Promise<OrderStatus>;
}The Pragmatic Approach
The Boy Scout Rule
"Leave the code better than you found it."
You don't need permission to:
- Add a missing test when fixing a bug
- Extract a well-named function from a complex method
- Rename a variable to be clearer
- Add a type annotation
- Document a confusing bit of logic
These small improvements compound over time.
Opportunistic Refactoring
Refactor as you work, not as a separate initiative:
When adding a feature:
- Write characterization tests around the area you're changing
- Refactor to make the change easy (this may be hard)
- Make the easy change
- Leave tests in place
When fixing a bug:
- Write a failing test that reproduces the bug
- Fix the bug (refactoring if needed to make it testable)
- Keep the test to prevent regression
When touching legacy code:
- Understand what it does (characterization tests help)
- Identify the seam where you can make changes
- Isolate dependencies to make testing possible
- Improve incrementally, don't try to fix everything
Start Small
From Sam Newman:
"Extract modest functionality slices over weeks, not months."
Small changes:
- Are easier to review
- Have less risk
- Can be rolled back easily
- Build team confidence
- Compound over time
Information Hiding
Maintain stable, minimal public interfaces:
typescript
// ❌ Exposing everything
export * from './internal-implementation';
// ✅ Explicit public API
export { UserService } from './public/user.service';
export { User, CreateUserDTO } from './public/types';
// internal/ directory not exportedThis is crucial whether you're:
- Creating modules within a monolith
- Extracting libraries
- Building microservices
Smaller interfaces reduce coupling and make future changes easier.
When and How to Apply These Patterns
Every Day: The Boy Scout Rule
Apply to all code you touch:
- Add tests incrementally
- Improve naming and clarity
- Extract functions to reduce complexity
- Document non-obvious decisions
Feature Development: Opportunistic Refactoring
When adding features to legacy areas:
- Identify the seam where new feature connects
- Write characterization tests if missing
- Refactor to create clean boundary
- Implement feature using current standards
- Consider strangler fig if replacing functionality
Bug Fixes: Test-Driven Stabilization
When fixing bugs in legacy code:
- Write failing test that reproduces bug
- Fix with minimal changes
- Refactor for clarity if safe
- Leave test as regression prevention
Planned Modernization: Incremental Displacement
When explicitly modernizing a legacy area:
- Understand outcomes - What are you trying to achieve?
- Map the territory - Event storm, identify seams, understand dependencies
- Plan small steps - Break into weeks-long increments
- Choose patterns - Strangler fig, branch by abstraction, parallel run, etc.
- Deliver incrementally - Each step adds value or reduces risk
- Measure progress - Define success metrics
- Transform practices - Update standards, documentation, team knowledge
Red Flags: When to Stop
Know when patterns aren't working:
- Changes take longer than estimated repeatedly
- Risk isn't decreasing with each increment
- Team confidence is dropping, not building
- Dependencies are spreading, not shrinking
- Technical debt is accumulating faster than it's being paid
If you see these signs:
- Reassess your approach
- Consider a smaller scope
- Get help from others who've solved similar problems
- Be willing to try a different strategy
Key Takeaways
- Legacy is inevitable - Standards evolve faster than systems can keep up
- Avoid big rewrites - They rarely succeed and stop value delivery
- Work incrementally - Small, reversible changes manage risk
- Tests are essential - Characterization tests enable safe change
- Find seams - Identify boundaries where you can make changes safely
- Use patterns - Strangler fig, branch by abstraction, and displacement patterns provide proven approaches
- Technology is only half - Organizational practices and culture matter equally
- Start small - The Boy Scout Rule applies to legacy modernization
- Deliver value continuously - Each increment should improve the system
- Plan for transition - Transitional architecture should be removed, not permanent
Further Reading
- "Working Effectively with Legacy Code" by Michael Feathers - Essential techniques for testing and refactoring untested code
- "Monolith to Microservices" by Sam Newman - Decomposition patterns applicable within or across service boundaries
- Patterns of Legacy Displacement by Ian Cartwright, Rob Horn, and James Lewis - Strategic patterns for incremental modernization
- "Refactoring" by Martin Fowler - Catalog of safe refactoring techniques
- "Building Evolutionary Architectures" by Neal Ford, Rebecca Parsons, Patrick Kua - Designing systems for change
Related Concepts
- Clean Architecture - Creating boundaries that facilitate change
- Modular Monolith - Building modularity within a single deployment
- Test-Driven Development - Using tests to enable safe refactoring
- Repository Pattern - Abstracting data access for flexibility