Michael Feathers' Seam Concept

The surgical technique for making untestable legacy code testable

The Testing Problem

You have legacy code with:

  • Hard-coded dependencies (new Database())
  • Static method calls (Logger.getInstance())
  • Direct new Date() calls
  • Third-party API calls in business logic

You can't test it without changing it.

But changing it without tests is risky.

What is a Seam?

Michael Feathers' definition from Working Effectively with Legacy Code:

"A place where you can alter behavior in your program without editing in that place."

Every seam has an enabling point - where you decide which behavior to use.

The seam is IN the code. The enabling point is OUTSIDE.

Three Types of Seams

Feathers identified three categories:

  1. Object Seam - polymorphism and dependency injection
  2. Link Seam - module system substitution at build time
  3. Preprocessing Seam - compiler directives (#ifdef)

For TypeScript/JavaScript: Object seams are your primary tool.

Link seams are occasionally useful. Preprocessing seams rarely apply.

Object Seam: Before

// Untestable: Hard-coded dependency
class OrderProcessor {
  processOrder(order: Order) {
    const payment = new PaymentGateway() // Can't mock this
    payment.charge(order.total)
    
    // What if charge() fails?
    // What if we need to test without real payment?
  }
}

Problem: No way to substitute test behavior.

Object Seam: After

// Testable: Dependency injection creates the seam
class OrderProcessor {
  constructor(private payment: IPaymentGateway) {} // SEAM
  
  processOrder(order: Order) {
    this.payment.charge(order.total) // Behavior varies here
  }
}

// ENABLING POINT (in test setup):
const mockPayment = new MockPaymentGateway()
const processor = new OrderProcessor(mockPayment)

The seam is in processOrder(). The enabling point is the constructor call.

// timeProvider.ts (production)
export function getCurrentTime(): Date {
  return new Date()
}

// timeProvider.test.ts (test environment)
export function getCurrentTime(): Date {
  return new Date('2024-01-01') // Fixed time
}

// Code under test
import { getCurrentTime } from './timeProvider'

function isExpired(expiryDate: Date): boolean {
  return getCurrentTime() > expiryDate // SEAM
}

Enabling point: Build configuration or import path mapping.

Creating Seams: Extract Interface

// Step 1: Legacy singleton
class Logger {
  static getInstance() { /* ... */ }
}
Database.query('SELECT...') // Static call everywhere

// Step 2: Extract interface
interface ILogger {
  log(message: string): void
}

interface IDatabase {
  query(sql: string): Result
}

// Step 3: Inject dependencies
class UserService {
  constructor(
    private logger: ILogger,
    private db: IDatabase
  ) {} // Two seams created
}

Real-World Seam Scenarios

1. Singleton preventing tests

  • Problem: Logger.getInstance().log() everywhere
  • Solution: Extract ILogger, inject instance

2. Static database calls

  • Problem: Database.query() makes tests slow
  • Solution: Inject IDatabaseConnection, use in-memory for tests

3. Time-dependent logic

  • Problem: new Date() makes tests non-deterministic
  • Solution: Inject IClock with now() method

4. Third-party API integration

  • Problem: HTTP calls make tests unreliable
  • Solution: Extract IApiClient, mock in tests

When to Use Seams

Use seams when:

  • Adding tests to untestable legacy code
  • Hard-coded dependencies block testing
  • Need to mock external systems (database, API, filesystem)
  • Code is too risky to refactor without tests first
  • Migrating incrementally (Strangler Fig pattern)

Don't use seams when:

  • Writing new code (use proper DI from start)
  • Code is safe to rewrite completely
  • The seam is more complex than the code
  • You have full control to refactor properly

Seams are often temporary stepping stones, not permanent solutions.

Common Misconceptions

"Seams = Dependency Injection"

  • Reality: DI creates object seams, but link/preprocessing seams don't use DI

"Seams are only for testing"

  • Reality: Also used for feature flags, A/B testing, legacy migration

"Need to refactor entire codebase"

  • Reality: Add seams surgically, only where needed

"Seams require interfaces in all languages"

  • Reality: Duck typing in Python/JS doesn't need explicit interfaces

"Seams are permanent"

  • Reality: Often temporary until you can refactor confidently

Seams and Other Patterns

Seams enable and relate to other patterns:

  • Dependency Injection - primary way to implement object seams
  • Strategy Pattern - creates seams by design
  • Adapter Pattern - adapters serve as seams for legacy integration
  • Strangler Fig - uses seams (facades) to route traffic between old/new systems
  • Feature Flags - runtime seams with enabling points in configuration

Understanding seams helps you recognize these patterns in the wild.

Your Action Plan

Step 1: Find untestable code with hard dependencies Step 2: Identify where you need to vary behavior (the seam location) Step 3: Extract an interface or create abstraction Step 4: Use constructor injection (object seam) Step 5: Write tests with mock implementations Step 6: Once tested, refactor confidently toward better design

Start small. One seam at a time. Build your test safety net.

Learn More

Primary Source: Working Effectively with Legacy Code by Michael Feathers (2004)

  • Chapter 4: "The Seam Model" (pages 29-40)

Online Resources:

  • Michael Feathers: informit.com/articles/article.aspx?p=359417
  • Martin Fowler: martinfowler.com/bliki/LegacySeam.html

Next Steps: Apply seams to enable characterization tests, then use Strangler Fig for incremental modernization.

The seam concept is your key to unlocking legacy code.

1 / 0
Seams: How to Test Legacy Code