Understanding Seams

A "seam" is one of the most powerful concepts for working with legacy code. Coined by Michael Feathers in "Working Effectively with Legacy Code," a seam is a place where you can alter behavior in your program without editing code at that location. Understanding seams is essential for testing and refactoring legacy systems.

Seams provide escape hatches—places where you can inject test doubles, swap implementations, or alter behavior without massive refactoring.

Video Summary

The video presentation explores seams in detail:

  • Michael Feathers' definition of seams and enabling points
  • Different types of seams in modern applications
  • How to identify seams in existing legacy code
  • Creating new seams to enable testing
  • Using seams to break dependencies
  • Real-world examples of exploiting seams
  • The relationship between seams and dependency injection

Key Concepts

1. What is a Seam?

Formal definition: A seam is a place where you can alter behavior in your program without editing that place.

Every seam has an enabling point: A place where you can make the decision to use one behavior or another.

// The seam: getConfig() can return different values
function processOrder(order: Order) {
  const config = getConfig(); // SEAM

  if (config.validateAddresses) {
    validateAddress(order.shippingAddress);
  }

  return saveOrder(order);
}

// The enabling point: where we override getConfig
// In production
global.getConfig = () => productionConfig;

// In tests
global.getConfig = () => testConfig;

2. Types of Seams

Object Seams (Method Override)

class OrderProcessor {
  processOrder(order: Order) {
    const customer = this.fetchCustomer(order.customerId); // SEAM
    return this.applyDiscounts(customer, order);
  }

  fetchCustomer(customerId: string): Customer {
    // Real implementation hits database
    return database.query('SELECT * FROM customers WHERE id = ?', [customerId]);
  }

  applyDiscounts(customer: Customer, order: Order) {
    // Business logic we want to test
  }
}

// Testing: Override the seam
class TestableOrderProcessor extends OrderProcessor {
  fetchCustomer(customerId: string): Customer {
    // Enabling point: return test data instead
    return { id: customerId, name: 'Test Customer', discountRate: 0.1 };
  }
}

// Test using the subclass
const processor = new TestableOrderProcessor();
const result = processor.processOrder(testOrder);
expect(result.total).toBe(90); // 10% discount applied

Preprocessing Seams (Module System)

// payment.ts - module with seam
import { chargeCard } from './stripe';

export function processPayment(amount: number) {
  return chargeCard(amount); // SEAM: import can be mocked
}

// payment.test.ts - enabling point is mock
import { processPayment } from './payment';
import * as stripe from './stripe';

jest.mock('./stripe', () => ({
  chargeCard: jest.fn().mockResolvedValue({ success: true })
}));

test('processes payment', async () => {
  const result = await processPayment(100);
  expect(result.success).toBe(true);
  expect(stripe.chargeCard).toHaveBeenCalledWith(100);
});

Link Seams (Dependency Injection)

// The seam: dependencies are injected
class EmailService {
  constructor(
    private transport: EmailTransport // SEAM
  ) {}

  sendEmail(to: string, subject: string, body: string) {
    return this.transport.send({ to, subject, body });
  }
}

// Enabling point: what we inject
// Production
const emailService = new EmailService(new SMTPTransport());

// Testing
const emailService = new EmailService(new MockTransport());

Temporal Seams (Time-based)

// The seam: getCurrentTime can be controlled
function isPromotionActive(promotion: Promotion): boolean {
  const now = getCurrentTime(); // SEAM
  return now >= promotion.startDate && now <= promotion.endDate;
}

// Enabling point: override time source
// Production
const getCurrentTime = () => new Date();

// Testing
const getCurrentTime = () => new Date('2024-01-15'); // Fixed time

3. Identifying Seams in Legacy Code

Look for:

Global functions that can be overridden

// Database.query is a seam
const result = Database.query('SELECT * FROM users');

// Can override in tests
global.Database = {
  query: () => mockData
};

Constructor calls

// Seam: what class gets instantiated
function createOrder() {
  const validator = new AddressValidator(); // SEAM
  return validator.validate(address);
}

// Extract to enable testing
function createOrder(validator = new AddressValidator()) {
  return validator.validate(address);
}

Import statements

// Seam: imported module
import { fetchUser } from './api';

function getUserData(id: string) {
  return fetchUser(id); // SEAM
}

// Can mock the import
jest.mock('./api');

Method calls that can be overridden

class ReportGenerator {
  generate() {
    const data = this.fetchData(); // SEAM
    return this.formatReport(data);
  }

  fetchData() {
    // Complex, slow operation
  }

  formatReport(data: any) {
    // What we want to test
  }
}

4. Creating New Seams

When code lacks seams, create them through minimal refactoring:

Extract method to create object seam

// Before: No seam
function processOrder(order: Order) {
  const customer = db.query('SELECT * FROM customers WHERE id = ?', [order.customerId]);
  // ... more logic
}

// After: Method is a seam
function processOrder(order: Order) {
  const customer = this.getCustomer(order.customerId); // NEW SEAM
  // ... more logic
}

function getCustomer(id: string): Customer {
  return db.query('SELECT * FROM customers WHERE id = ?', [id]);
}

Extract parameter to create link seam

// Before: Hard dependency
function sendNotification(message: string) {
  EmailClient.send(message); // No seam
}

// After: Injected dependency creates seam
function sendNotification(message: string, client = EmailClient) {
  client.send(message); // SEAM via parameter
}

// Testing
sendNotification('test', mockClient);

Real-World Applications

Example 1: Testing Code with Date Dependencies

Problem: Code depends on current time

function calculateAge(birthDate: Date): number {
  const today = new Date(); // No seam, can't test aging
  const ageMs = today.getTime() - birthDate.getTime();
  return Math.floor(ageMs / (1000 * 60 * 60 * 24 * 365.25));
}

// How do you test someone is age 21 tomorrow?

Solution: Create temporal seam

function calculateAge(birthDate: Date, now = new Date()): number {
  const ageMs = now.getTime() - birthDate.getTime();
  return Math.floor(ageMs / (1000 * 60 * 60 * 24 * 365.25));
}

// Testing: Control time via seam
test('age increases on birthday', () => {
  const birthDate = new Date('2000-01-15');

  const ageBefore = calculateAge(birthDate, new Date('2024-01-14'));
  expect(ageBefore).toBe(23);

  const ageAfter = calculateAge(birthDate, new Date('2024-01-15'));
  expect(ageAfter).toBe(24);
});

Example 2: Testing Code with External API Calls

Problem: Direct API dependency

async function getUserProfile(userId: string) {
  // Direct fetch, no seam
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const data = await response.json();
  return {
    name: data.name,
    email: data.email,
  };
}

Solution: Create module seam

// api.ts - Extract API calls
export async function fetchUserFromAPI(userId: string) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  return response.json();
}

// users.ts - Business logic with seam
import { fetchUserFromAPI } from './api';

export async function getUserProfile(userId: string) {
  const data = await fetchUserFromAPI(userId); // SEAM
  return {
    name: data.name,
    email: data.email,
  };
}

// users.test.ts - Mock at the seam
jest.mock('./api', () => ({
  fetchUserFromAPI: jest.fn().mockResolvedValue({
    name: 'Test User',
    email: 'test@example.com',
  })
}));

test('formats user profile', async () => {
  const profile = await getUserProfile('123');
  expect(profile).toEqual({
    name: 'Test User',
    email: 'test@example.com',
  });
});

Example 3: Testing Legacy Singleton

Problem: Singleton with no seam

class Database {
  private static instance: Database;

  private constructor() {
    // Real database connection
  }

  static getInstance(): Database {
    if (!this.instance) {
      this.instance = new Database();
    }
    return this.instance;
  }

  query(sql: string): any[] {
    // Real database query
  }
}

// Code using singleton
function getUsers() {
  return Database.getInstance().query('SELECT * FROM users'); // No seam
}

Solution: Add seam without breaking existing code

class Database {
  private static instance: Database;

  private constructor() {
    // Real database connection
  }

  static getInstance(): Database {
    if (!this.instance) {
      this.instance = new Database();
    }
    return this.instance;
  }

  // NEW SEAM: Allow instance override for testing
  static setInstance(instance: Database) {
    this.instance = instance;
  }

  static resetInstance() {
    this.instance = undefined!;
  }

  query(sql: string): any[] {
    // Real database query
  }
}

// Testing: Use the seam
const mockDb = {
  query: jest.fn().mockReturnValue([{ id: 1, name: 'Test' }])
} as any;

beforeEach(() => {
  Database.setInstance(mockDb);
});

afterEach(() => {
  Database.resetInstance();
});

test('gets users', () => {
  const users = getUsers();
  expect(users).toHaveLength(1);
  expect(mockDb.query).toHaveBeenCalledWith('SELECT * FROM users');
});

Example 4: Testing Code with Random Numbers

Problem: Randomness is untestable

function generateCouponCode(): string {
  const random = Math.random().toString(36).substring(2, 8); // No seam
  return `COUPON-${random.toUpperCase()}`;
}

// Every call returns different value, can't assert

Solution: Create random number seam

function generateCouponCode(randomFn = Math.random): string {
  const random = randomFn().toString(36).substring(2, 8);
  return `COUPON-${random.toUpperCase()}`;
}

// Testing: Control randomness
test('generates coupon code', () => {
  const mockRandom = () => 0.123456789;
  const code = generateCouponCode(mockRandom);

  expect(code).toBe('COUPON-0JZGZD'); // Deterministic!
});

Common Pitfalls

1. Creating Too Many Seams

Problem: Every function becomes parameterized, code is unreadable.

// Too many seams!
function processOrder(
  order: Order,
  db = Database,
  logger = Logger,
  emailer = Emailer,
  validator = Validator,
  time = Date.now
) {
  // 20 lines of business logic
}

Solution: Use dependency injection at class level, not function level:

class OrderProcessor {
  constructor(
    private db: Database,
    private logger: Logger,
    private emailer: Emailer,
    private validator: Validator
  ) {}

  processOrder(order: Order) {
    // Clean business logic
  }
}

2. Seams That Leak Implementation

Problem: Seam exposes internal implementation details.

// Bad: SQL query is implementation detail
function getUsers(queryFn = (sql) => db.query(sql)) {
  return queryFn('SELECT * FROM users');
}

Solution: Seam should represent domain concept:

// Good: Repository abstracts data access
function getUsers(userRepo = defaultUserRepo) {
  return userRepo.findAll();
}

3. Global State as Seam

Problem: Using global variables as seams creates coupling.

// Brittle: Tests depend on global state
global.mockMode = true;

function getData() {
  if (global.mockMode) return mockData;
  return realData();
}

Solution: Explicit dependencies over global state:

function getData(dataSource = realDataSource) {
  return dataSource.fetch();
}

Key Takeaways

  • Seams are places where you can alter behavior without editing that location
  • Every seam has an enabling point where you choose which behavior to use
  • Common seam types: object seams, preprocessing seams, link seams, temporal seams
  • Identify existing seams in legacy code to enable testing
  • Create new seams through minimal refactoring when necessary
  • Seams enable testing without requiring massive rewrites
  • Dependency injection is a systematic way to create seams
  • Don't overuse seams—find the right balance between testability and simplicity

Further Reading

Sunsetting - Understanding Seams | Sunsetting Learn