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
- Working Effectively with Legacy Code - Chapters 4 and 25 on seams
- Dependency Injection Principles, Practices, and Patterns - Systematic approach to creating seams
- The Art of Unit Testing - Testing patterns including seams
- Test-Driven Development by Kent Beck - TDD principles related to testability