Finding the Right Seam in a Legacy System
When you need to test, monitor, or gradually migrate code inside an aging system, the most useful tool is a seam: a place where the program’s behavior can be changed without editing the code at that spot. Michael Feathers introduced this concept in Working Effectively with Legacy Code, defining a seam as “a place where you can alter behavior in your program without editing in that place.”
Consider a function that calculates order pricing.
// TypeScript
export async function calculatePrice(order:Order) {
const itemPrices = order.items.map(i => calculateItemPrice(i))
const basePrice = itemPrices.reduce((acc, i) => acc + i.price, 0)
const discount = calculateDiscount(order)
const shipping = await calculateShipping(order)
const adjustedShipping = applyShippingDiscounts(order, shipping)
return basePrice + discount + adjustedShipping
}
The calculateShipping function makes an expensive call to an external service. During tests, repeating that call is undesirable; instead, you would substitute a deterministic stub. But you cannot change the body of calculatePrice for a test, so you need a seam around calculateShipping that lets the test redirect the call.
The Simplest Seam: Passing a Function
The direct approach is to pass calculateShipping into the function as an argument:
export async function calculatePrice(order:Order, shippingFn: (o:Order) => Promise<number>) {
const itemPrices = order.items.map(i => calculateItemPrice(i))
const basePrice = itemPrices.reduce((acc, i) => acc + i.price, 0)
const discount = calculateDiscount(order)
const shipping = await shippingFn(order)
const adjustedShipping = applyShippingDiscounts(order, shipping)
return basePrice + discount + adjustedShipping
}
A unit test can then supply a simple stub:
const shippingFn = async (o:Order) => 113 expect(await calculatePrice(sampleOrder, shippingFn)).toStrictEqual(153)
Every seam needs an enabling point: the location where you choose which behavior to activate. By passing the function in as a parameter, the seam’s enabling point moves to the function caller—in this case, the test code. Once this seam exists, you can check how applyShippingDiscounts reacts to varied shipping values without further modifying the production source code.
When Parameter Threading Doesn’t Work
Altering the signature of calculateShipping may not always be practical, especially if you want to avoid threading a new parameter through a deep legacy call stack. In those situations, a lookup-based seam is more suitable. A service locator is one classical example:
export async function calculatePrice(order:Order) {
const itemPrices = order.items.map(i => calculateItemPrice(i))
const basePrice = itemPrices.reduce((acc, i) => acc + i.price, 0)
const discount = calculateDiscount(order)
const shipping = await ShippingServices.calculateShipping(order)
const adjustedShipping = applyShippingDiscounts(order, shipping)
return basePrice + discount + adjustedShipping
}
class ShippingServices {
static #soleInstance: ShippingServices
static init(arg?:ShippingServices) {
this.#soleInstance = arg || new ShippingServices()
}
static async calculateShipping(o:Order) {return this.#soleInstance.calculateShipping(o)}
async calculateShipping(o:Order) {return legacy_calcuateShipping(o)}
// ... more services
The locator can be subclassed to override its behavior:
class ShippingServicesStub extends ShippingServices {
calculateShippingFn: typeof ShippingServices.calculateShipping =
(o) => {throw new Error("no stub provided")}
async calculateShipping(o:Order) {return this.calculateShippingFn(o)}
// more services
This allows the test to activate the substitution through an enabling point:
const stub = new ShippingServicesStub() stub.calculateShippingFn = async (o:Order) => 113 ShippingServices.init(stub) expect(await calculatePrice(sampleOrder)).toStrictEqual(153)
The class-based locator demonstrates the general idea, but JavaScript and TypeScript offer a simpler style: place the adjustment directly into a module:
export let calculateShipping = legacy_calculateShipping
export function reset_calculateShipping(fn?: typeof legacy_calculateShipping) {
calculateShipping = fn || legacy_calculateShipping
}
The test would use it this way:
const shippingFn = async (o:Order) => 113 reset_calculateShipping(shippingFn) expect(await calculatePrice(sampleOrder)).toStrictEqual(153)
Each of these examples works because the seam mechanism relies on function lookup at runtime—so tests interact with well-defined stubs rather than real side-effect-heavy modules.
Why Seams Are More Than a Testing Aid
Feathers’ original motivation was getting legacy systems under test, which is often the necessary first step to working with them sensibly. But the value of seams extends beyond testability:
- Observability: A seam lets you insert probes into calls like
calculateShipping, which can reveal usage frequency and capture output for analysis. - Incremental Migration: A seam can be used to redirect selected traffic—say, high-value customers—to a new shipping calculator module, enabling the gradual displacement of legacy parts into a contemporary environment.
Introducing useful seams into production code frequently involves several months of discovery and careful changes. Even on a greenfield project, seaming is worth doing from the start: since every modern system eventually becomes legacy, building with accessible seams allows for long-term maintenance. This is one reason test-driven development remains so effective—it naturally produces the architectural seams you will need later. In practice, the ideal seam maker varies with language, framework, and system architecture, and the most sustainable seams are likely different in a legacy codebase than on new code, but noting that difference is the key to planning a manageable transition path.



