Breaking Dependencies in Legacy Code: Sprout, Wrap, and Seam Patterns
The first thing that goes wrong with legacy code is not the code itself. It is that you cannot test the part you need to change without standing up half of production. The database, the message bus, the auth service, the file system, the clock; everything the method touches is hard-wired into the method. You cannot get the method under test, so you cannot refactor it. So you change it carefully, and a year later somebody else changes it carefully, and the bug count keeps climbing.
Michael Feathers wrote a whole book about the way out of this. The core idea is that you do not need to test all of the legacy code to make progress. You need to break exactly enough dependencies to test the bit you are about to change, then change it. Three techniques do most of the work: sprout, wrap, and seams ↗. None of them require permission from the team that wrote the original code. None of them need a rewrite. They all leave the build green at every step.
This piece walks through each one with a real example, when to reach for which, and the failure modes you will hit on the way. It assumes you have already audited the codebase and decided this part is worth touching. If you have not, start there.
The Problem in One Function
Here is a method that looks innocent until you try to test it.
// src/orders/checkout.js
class Checkout {
completeOrder(orderId) {
const order = db.query(`SELECT * FROM orders WHERE id = ${orderId}`);
if (order.status !== 'pending') {
throw new Error('Order not in pending state');
}
const charge = stripe.charges.create({
amount: order.total,
currency: 'gbp',
customer: order.customerStripeId,
});
db.query(`UPDATE orders SET status = 'paid' WHERE id = ${orderId}`);
mailer.send(order.customerEmail, 'order-confirmation', { orderId });
analytics.track('order_completed', { orderId, total: order.total });
return charge.id;
}
}
You want to add VAT calculation before the Stripe charge. Easy in isolation, except this method touches the database, Stripe, an SMTP provider, and a third-party analytics service. To unit test it you would need to fake all four. There is no place to inject a fake without rewriting the method. That is what Feathers means when he says the code has no seams.
You have three sensible options, ranked by invasiveness.
- Sprout the new VAT logic into a separate tested unit and call it from one line.
- Wrap the whole method so the VAT calculation runs before the original.
- Introduce a seam by extracting a dependency, then test the modified method properly.
The right answer depends on how confident you are that the existing method works today, how much time you have, and how much of the surrounding code you can afford to touch. The rest of this piece is about picking between them.
Sprout Method: The Lowest-Risk Win
Sprout method is the first thing to try. The legacy method does not change shape. You only add a call to a brand new, fully tested function. Every new line you write is covered. Every existing line still works exactly as before.
The pattern is mechanical.
- Write the new behaviour as a pure function or a small class, with full unit tests.
- Find the single point in the legacy method where it needs to run.
- Add one line that calls into the new code.
For our checkout example, calculating VAT becomes a sprouted function.
// src/orders/vat.js (new file, fully tested)
export function calculateVat(total, country) {
const rate = country === 'GB' ? 0.20 : 0;
return Math.round(total * rate);
}
// src/orders/vat.test.js
import { calculateVat } from './vat.js';
test('calculates 20 percent VAT for UK orders', () => {
expect(calculateVat(1000, 'GB')).toBe(200);
});
test('returns zero VAT for non-UK orders', () => {
expect(calculateVat(1000, 'US')).toBe(0);
});
test('rounds VAT to the nearest pence', () => {
expect(calculateVat(1099, 'GB')).toBe(220);
});
// src/orders/checkout.js (one new line)
class Checkout {
completeOrder(orderId) {
const order = db.query(`SELECT * FROM orders WHERE id = ${orderId}`);
if (order.status !== 'pending') {
throw new Error('Order not in pending state');
}
const vat = calculateVat(order.total, order.country); // sprouted
const charge = stripe.charges.create({
amount: order.total + vat,
currency: 'gbp',
customer: order.customerStripeId,
});
db.query(`UPDATE orders SET status = 'paid' WHERE id = ${orderId}`);
mailer.send(order.customerEmail, 'order-confirmation', { orderId });
analytics.track('order_completed', { orderId, total: order.total });
return charge.id;
}
}
The legacy method now has one new line and one modified line. The pull request is small. A reviewer can read it in 90 seconds. If the VAT logic is wrong, the failure is local to vat.js and the tests will catch it. The legacy method’s behaviour is unchanged except for adding the VAT amount to the charge, which is exactly the requirement.
You will hit two objections. The first is “but the legacy method is still untested”. Correct, and that is fine for now. The aim is to stop the legacy method from getting worse, not to fix it in one go. Every sprout removes some pressure from the legacy method while keeping the new logic clean.
The second is “sprout method ducks the real problem”. Also true, sometimes. If the legacy method is on fire and breaks every week, sprouting more code into it is rearranging deckchairs. In that case, escalate to a seam.
Sprout Class: When the Sprout Grows Up
If the sprouted behaviour needs more than a single pure function, promote it from a method to a class. The pattern is identical: write the class with full tests, then instantiate and call it from one line inside the legacy method. The advantage is that the class can hold state, encapsulate multiple operations, and be reused elsewhere without dragging the legacy code with it.
A common shape is a calculator or policy object: new TaxPolicy(country, date).rateFor(productType). The legacy method does not know or care about the policy object’s internals.
Wrap Method: Adding Behaviour Around the Old Method
Wrap method is for when the new behaviour must run before or after the entire existing method, not somewhere inside it. Logging, retries, feature flags, audit trails, and metrics are the classic cases.
The mechanic is a rename plus a thin shim.
- Rename the existing method to something private, for example
_completeOrderLegacy. - Create a new method with the original name.
- Inside the new method, call the old one and add the new behaviour before, after, or both.
For our example, suppose you need an audit log entry every time an order completes, regardless of which code path called it.
class Checkout {
completeOrder(orderId) {
audit.log('order.complete.started', { orderId });
const chargeId = this._completeOrderLegacy(orderId);
audit.log('order.complete.succeeded', { orderId, chargeId });
return chargeId;
}
_completeOrderLegacy(orderId) {
// the original implementation, unchanged
}
}
Every caller of completeOrder now picks up the audit log automatically. The original method is byte-for-byte unchanged and its callers do not know the difference. If audit logging throws, you can catch and continue, or fail fast, depending on policy.
Two warnings.
The wrap is only safe when the new behaviour is genuinely additive. If the new code needs to decide whether to call the legacy method at all, you have moved into changing behaviour, and you need to test the wrapper plus the conditions. The cheap version of wrap method is when the inner call is always executed.
The wrap also costs you the original method name as a place to read the real logic. Future readers will land on the wrapper and have to chase the underscore-prefixed version. A clear naming convention plus a short comment in the wrapper saying “see _completeOrderLegacy for the original implementation” pays for itself.
Seams: When You Need to Test the Real Thing
Sprout and wrap let you add behaviour without testing the legacy code. Sooner or later you will need to test the legacy code itself. That is where seams come in.
A seam is a place where behaviour can change without editing in that place. The seam is not a refactor by itself; it is the precondition for testing.
In our checkout method, the dependencies are db, stripe, mailer, and analytics. None of them are seams: the method reaches out to module-level singletons. To create object seams, inject the dependencies through the constructor.
class Checkout {
constructor({ db, stripe, mailer, analytics }) {
this.db = db;
this.stripe = stripe;
this.mailer = mailer;
this.analytics = analytics;
}
completeOrder(orderId) {
const order = this.db.query(`SELECT * FROM orders WHERE id = ${orderId}`);
// ...rest unchanged
}
}
Now the constructor argument is the seam. In production you pass the real adapters. In tests you pass fakes that record calls and return canned data.
test('marks order paid when Stripe charge succeeds', () => {
const db = createFakeDb({
orders: [{ id: 1, status: 'pending', total: 1000, customerStripeId: 'cus_1' }],
});
const stripe = { charges: { create: () => ({ id: 'ch_test' }) } };
const mailer = { send: jest.fn() };
const analytics = { track: jest.fn() };
const checkout = new Checkout({ db, stripe, mailer, analytics });
const chargeId = checkout.completeOrder(1);
expect(chargeId).toBe('ch_test');
expect(db.query).toHaveBeenCalledWith(
"UPDATE orders SET status = 'paid' WHERE id = 1"
);
expect(mailer.send).toHaveBeenCalled();
});
The risky bit is the change from singleton access to dependency injection. That is a behaviour-changing refactor, so you write a characterisation test against the existing behaviour first, then introduce the seam, then run the test. If the test still passes, you have a seam without behavioural change.
The Three Kinds of Seam, Briefly
Feathers names seams by the enabling point.
| Seam type | Enabling point | Best for |
|---|---|---|
| Object seam | The object passed in | Modern JavaScript, Python, Java, Go |
| Link seam | Linker or module resolver | C, C++, occasionally Node CommonJS |
| Preprocessor seam | Compiler macros | C, C++ where neither of the above works |
In any modern object-oriented or dynamic language, object seams cover almost every real case. Link seams are useful when you cannot change the source of the dependency, for example a vendored binary or a system call. Preprocessor seams are a last resort that you should avoid unless you have no other option, because they are easy to misuse and hard to read.
A Worked Order: Sprout, Wrap, Seam
Faced with a tangled legacy method, the cheapest order is sprout, wrap, then seam.
- Sprout the new behaviour first. Every new line is covered. Nothing existing breaks.
- Wrap when the new behaviour must run before or after the whole method.
- Introduce a seam when you need to test or change the legacy code itself.
You can mix the three in the same module. The wrap method shim might call a sprouted helper. The seam might be introduced specifically so a sprouted unit can be tested against a fake. The point is that each technique is independently safe, and you can stop after any one of them and ship.
Common Traps
The most common mistake is treating these patterns as a stepping stone to a rewrite. Sprout and wrap are not waypoints on the road to “we will rewrite this properly later”. They are a way of strangling the old code by gradual replacement ↗, with the strangler fig pattern at module scale and these dependency-breaking techniques at method scale. The new code is the real code. The legacy method is the scaffold that holds production up until the new code can carry the weight.
The second mistake is sprouting into the same file. A sprout that lives next to the legacy method tends to absorb the legacy method’s bad habits within months. Put new code in a new file with its own tests. The physical distance makes future readers see it as a separate unit, not a piece of the legacy code.
The third mistake is over-injecting. Dependency injection is a means to an end. If you inject every object the legacy method touches, you end up with a constructor that takes 14 arguments and is harder to use than the original. Inject only the dependencies you need to swap in tests. Everything else stays as a direct call.
The fourth mistake is forgetting that tests on the new code do not test the integration with the old code. A wrap method that calls the legacy method still depends on the legacy method’s behaviour. You need at least one integration or end-to-end test that exercises the wrapped method against real or contract-compatible dependencies. The unit tests cover the new behaviour. The integration test catches the seam at the wrap boundary.
Where This Fits
Dependency-breaking techniques are the half-step between not being able to test the legacy code and being able to refactor it. The full sequence in practice usually looks like this.
- Audit the codebase to find the hot spots.
- Sprout or wrap to stop the rot from getting worse while you learn the code.
- Introduce seams in the methods you need to change next.
- Refactor under characterisation tests once the seams are in place.
- Repeat.
Most teams who say they have “tried Working Effectively with Legacy Code and it did not help” have tried to refactor without first introducing seams. Without a seam, the only way to refactor is to read the code, hold your breath, and ship. With a seam, the legacy method behaves like ordinary code: you can write a test, change the code, run the test.
If you are working on a long-running legacy migration, pair this with The Developer’s Guide to Working with Legacy Code, which covers the higher-level strangler fig pattern and team-level practices. The two pieces are complementary: this one is about the methods, that one is about the system.
A Short Checklist for Tomorrow
When you next need to change a legacy method, before you start typing in the existing function, ask:
- Can I sprout this change into a new tested unit and call it from one line?
- If not, can I wrap the existing method to add behaviour around it?
- If neither, what is the smallest dependency I can extract to create a seam?
- Have I written a characterisation test for the current behaviour first?
- Is the new code in a new file, with its own tests, that does not depend on the legacy module?
Answer those before you change a single character inside the legacy method and you will avoid the great majority of legacy-refactor disasters. The book itself, Working Effectively with Legacy Code ↗, is worth reading in full if you do this work often. It is twenty years old and almost every page still applies.
Frequently asked questions
What is a seam in legacy code?
A seam is a place where you can alter behaviour in your program without editing in that place. Michael Feathers defined the term in Working Effectively with Legacy Code. The classic example is a method that calls a database directly: there is no seam, because you cannot swap the database without changing the method itself. If the database call goes through an injected interface, the constructor argument becomes a seam: you can pass a fake in a test without touching the method body.
When should I use the sprout method instead of changing the existing code?
Use sprout method when the surrounding code is too tangled to test, the deadline is tomorrow, and you need to add new behaviour without breaking what already works. You write the new logic in a fresh, fully tested method or class, then call it from a single line inside the legacy code. The legacy code stays untested for now, but every line you added is covered. It is the fastest way to stop the rot from getting worse.
What is the difference between sprout method and wrap method?
Sprout method adds new behaviour by calling out to a new, tested unit from inside the existing method. Wrap method renames the existing method, then creates a new method with the original name that calls both the new behaviour and the renamed original. Use sprout when the new behaviour happens at one point inside the legacy method. Use wrap when the new behaviour must happen before or after the entire old method runs, for example logging, retries, or feature flag gates.
Do I need to write characterisation tests before breaking dependencies?
Yes, if you are changing the legacy code itself. Characterisation tests pin the current behaviour, so when you introduce a seam by extracting an interface or splitting a method, you will know immediately if the behaviour drifts. If you are using sprout method and only adding a call to new code, the new code carries its own unit tests and the legacy method's behaviour is unchanged, so a characterisation test is optional but still cheap insurance.
What is an object seam versus a link seam versus a preprocessor seam?
Feathers names three seam types by enabling point. An object seam swaps behaviour by passing a different object, the cleanest option in object-oriented code. A link seam swaps behaviour at link time, for example by substituting a fake library binary, useful in C and C++. A preprocessor seam uses macros or compiler flags to redirect calls, the last resort when neither of the others is available. In modern JavaScript, Python, or Java, object seams cover almost every real case.
Enjoyed this article? Get more developer tips straight to your inbox.
Comments
Join the conversation. Share your experience or ask a question below.
No comments yet. Be the first to share your thoughts.