Skip to content

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:

  1. Identify - Parts of the system to migrate
  2. Implement - New functionality (in a module, service, or microservice)
  3. 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 code

Branch by Abstraction

Make changes safely while keeping the system running:

  1. Create abstraction for functionality to replace
  2. Update clients to use the abstraction
  3. Create new implementation behind the abstraction
  4. Switch abstraction to new implementation (gradually or all at once)
  5. 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 implementation

Parallel Run

Run old and new implementations simultaneously to validate behavior:

  1. Route requests to both implementations
  2. Use one as source of truth (initially the legacy system)
  3. Compare results and log differences
  4. Fix discrepancies in new implementation
  5. Switch source of truth to new implementation
  6. 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:

  1. Write characterization tests around the area you're changing
  2. Refactor to make the change easy (this may be hard)
  3. Make the easy change
  4. Leave tests in place

When fixing a bug:

  1. Write a failing test that reproduces the bug
  2. Fix the bug (refactoring if needed to make it testable)
  3. Keep the test to prevent regression

When touching legacy code:

  1. Understand what it does (characterization tests help)
  2. Identify the seam where you can make changes
  3. Isolate dependencies to make testing possible
  4. 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 exported

This 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:

  1. Identify the seam where new feature connects
  2. Write characterization tests if missing
  3. Refactor to create clean boundary
  4. Implement feature using current standards
  5. Consider strangler fig if replacing functionality

Bug Fixes: Test-Driven Stabilization

When fixing bugs in legacy code:

  1. Write failing test that reproduces bug
  2. Fix with minimal changes
  3. Refactor for clarity if safe
  4. Leave test as regression prevention

Planned Modernization: Incremental Displacement

When explicitly modernizing a legacy area:

  1. Understand outcomes - What are you trying to achieve?
  2. Map the territory - Event storm, identify seams, understand dependencies
  3. Plan small steps - Break into weeks-long increments
  4. Choose patterns - Strangler fig, branch by abstraction, parallel run, etc.
  5. Deliver incrementally - Each step adds value or reduces risk
  6. Measure progress - Define success metrics
  7. 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

  1. Legacy is inevitable - Standards evolve faster than systems can keep up
  2. Avoid big rewrites - They rarely succeed and stop value delivery
  3. Work incrementally - Small, reversible changes manage risk
  4. Tests are essential - Characterization tests enable safe change
  5. Find seams - Identify boundaries where you can make changes safely
  6. Use patterns - Strangler fig, branch by abstraction, and displacement patterns provide proven approaches
  7. Technology is only half - Organizational practices and culture matter equally
  8. Start small - The Boy Scout Rule applies to legacy modernization
  9. Deliver value continuously - Each increment should improve the system
  10. 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