Legacy Code Addition Techniques
by @quochungto
Add new functionality to untested legacy code using Sprout Method, Sprout Class, Wrap Method, or Wrap Class — whichever best fits the dependency profile. Use...
clawhub install bookforge-legacy-code-addition-techniques📖 About This Skill
name: legacy-code-addition-techniques description: "Add new functionality to untested legacy code using Sprout Method, Sprout Class, Wrap Method, or Wrap Class — whichever best fits the dependency profile. Use whenever a developer needs to add a feature, log statement, validation, or any new behavior to legacy code that they can't easily test — 'I have to add this feature fast', 'no time for a big refactor', 'just need to log this', 'add a check to existing method', 'need to add behavior without breaking legacy', 'sprout method', 'sprout class', 'wrap method', 'wrap class', 'decorator for legacy'. Activates for 'quick change to legacy', 'under time pressure', 'can't test this class but need to add a feature', 'extend without editing'." version: 1.0.0 homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/working-effectively-with-legacy-code/skills/legacy-code-addition-techniques metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}} status: draft source-books: - id: working-effectively-with-legacy-code title: "Working Effectively with Legacy Code" authors: ["Michael C. Feathers"] chapters: [6] domain: software-engineering tags: [legacy-code, refactoring, testing, software-engineering, feature-addition] depends-on: - legacy-code-change-algorithm - safe-legacy-editing-discipline execution: tier: 1 mode: full inputs: - type: codebase description: "Existing source code + description of the new behavior to add" tools-required: [Read, Edit, Bash] tools-optional: [Grep] mcps-required: [] environment: "Codebase in an OO language. Test framework configured." discovery: goal: "Add new behavior to legacy code using the least-invasive Sprout or Wrap technique." tasks: - "Classify the scope and temporal-coupling nature of the new behavior" - "Select Sprout Method / Sprout Class / Wrap Method / Wrap Class via 2×2 matrix" - "Execute the chosen technique step-by-step with TDD" - "Leave old code in place; flag for later proper refactoring" audience: roles: [software-engineer, backend-developer, senior-developer] experience: intermediate when_to_use: triggers: - "Time-constrained feature addition to legacy code" - "New code must be tested but surrounding code is untestable" - "Behavior must co-execute with existing behavior but stay separable" prerequisites: - skill: legacy-code-change-algorithm why: "Sprout/Wrap is Step 5 (make change) when you can't break enough dependencies upfront" - skill: safe-legacy-editing-discipline why: "Preserve Signatures and Single-Goal Editing apply during the wrapping step" not_for: - "Code that IS already under test — use TDD directly" - "Major new features that justify structural refactoring upfront" environment: codebase_required: true codebase_helpful: true works_offline: true quality: scores: {with_skill: null, baseline: null, delta: null} tested_at: null eval_count: null assertion_count: 13 iterations_needed: null
Legacy Code Addition Techniques (Sprout & Wrap)
When to Use
You need to add new behavior to a legacy codebase — a feature, a logging statement, a validation check, an integration hook — but you cannot get the surrounding code under test right now. The existing method or class is too entangled to test directly.
Any of these conditions apply:
This skill is Step 5 of legacy-code-change-algorithm. If you haven't identified change points and test points yet, start there. Return here when you've determined that the change must be made without full test coverage of the source class.
Before executing, have: 1. Target class/method name(s) where the new behavior must be introduced 2. A one-sentence description of the new behavior 3. An answer to: "Does this behavior need to execute alongside the existing call, or is it standalone?" 4. An answer to: "Can the source class be instantiated in a test harness right now?"
Context & Input Gathering
Required
Observable from Codebase
Process
Step 1: Classify Scope
ACTION: Determine whether the new behavior is *method-level* (a single new operation added at one call site in one method) or *class-level* (the behavior is logically a new abstraction, or the source class is so heavily coupled that even a sprouted method can't be tested on it).
WHY: Technique selection depends on whether you can get *any* testable unit out of the source class. Method-level scope means you can stay inside the source class. Class-level scope means you need to leave the source class entirely.
Rule of thumb:
new SourceClass(...) in a test harness in under 30 minutes → method-level scope is viable.ARTIFACT: Declare scope in your working notes: scope = method-level or scope = class-level.
Step 2: Classify Temporal Coupling
ACTION: Determine whether the new behavior is *independent* (it happens separately, can be called on its own) or *temporally coupled* (it must co-execute every time the original method is called).
WHY: Temporal coupling is the reason you'd be tempted to add code inline at the bottom of an existing method — "it has to happen at the same time." Wrap techniques explicitly address this by making the co-execution visible and deliberate at the callsite rather than buried inside the method. Sprout techniques assume the new behavior stands alone.
Rule of thumb:
pay() must also log" → temporally coupled → WrapARTIFACT: Declare coupling in your working notes: coupling = temporally-coupled or coupling = independent.
Step 3: Apply the 2×2 Selector
ACTION: Cross scope and coupling to select the technique:
SPROUT WRAP
(independent) (co-executes)
┌─────────────────┬──────────────────┐
METHOD-LEVEL │ Sprout Method │ Wrap Method │
├─────────────────┼──────────────────┤
CLASS-LEVEL │ Sprout Class │ Wrap Class │
└─────────────────┴──────────────────┘
Why each quadrant:
| Technique | When to prefer | Key advantage | Key disadvantage | |---|---|---|------| | Sprout Method | Method-level + independent | Clearly separates new code from old; new method is fully testable | Gives up on getting source method under test; leaves source method in odd state | | Sprout Class | Class-level + independent | Lets you TDD even when source class can't be constructed | Conceptually fragmenting — new class may seem disconnected | | Wrap Method | Method-level + co-executes | Makes temporal coupling explicit; does not grow the original method | Must invent a new name for the original method's logic | | Wrap Class | Class-level + co-executes | Fully separates new behavior from old using the Decorator pattern | More structural overhead for simple additions |
Additional selection rules:
new DatabaseConnection() that you cannot fake quickly).ARTIFACT: Decision recorded: technique = [Sprout Method | Sprout Class | Wrap Method | Wrap Class].
Step 4: Execute the Chosen Technique
Execute the step-by-step mechanics for your chosen technique. Full reference mechanics for all four are in references/four-techniques-mechanics.md. The most common two cases are inlined below.
#### Sprout Method (method-level + independent)
1. Identify the exact location in the source method where the new functionality must happen. 2. Write (but comment out) a call to a new method that will do the work. Decide its name and arguments now, before writing it. This forces you to think about its interface in context. 3. Determine which local variables the new method needs from the source method. These become its parameters. 4. Determine whether the new method must return a value to the source method. If yes, assign its return value to a variable in the call. 5. Develop the new sprouted method using test-driven development — write tests for the sprouted method in isolation; make them pass. 6. Uncomment the call in the source method to activate the integration.
WHY each step matters:
#### Wrap Method (method-level + co-executes)
1. Identify the method whose every call must include the new behavior.
2. Rename the existing method to something that describes what it actually does (e.g., pay() → dispatchPayment()). Apply Preserve Signatures: copy the signature exactly — same parameter types, same return type. Make the renamed method private.
3. Create a new method with the original name and signature. This is the new public entry point.
4. In the new method, call both the renamed original method and a new method that you develop using TDD for the new behavior. Order (before or after) depends on the requirement.
WHY each step matters:
pay() and get both behaviors transparently.pay() itself cannot be tested in isolation.For Sprout Class and Wrap Class step-by-step mechanics, see references/four-techniques-mechanics.md.
Step 5: Develop New Code with TDD
ACTION: Regardless of technique chosen, write and pass tests for the new sprouted method, new sprouted class, or new wrapped method *before* integrating.
WHY: The whole point of Sprout/Wrap is to create a seam between tested new code and untested old code. If you skip tests on the new code, you lose the only testing benefit these techniques provide. The surrounding code has no tests — but the new code can and must have tests.
HOW: 1. Write the simplest test that fails because the new method/class doesn't exist yet. 2. Implement just enough to make it pass. 3. Refactor the new code. It is clean code; you can afford to refactor it. 4. Repeat until the behavior described in your requirement is fully tested.
Step 6: Integrate
ACTION: Activate the new code within the legacy call site.
Run the full build and any available tests (even characterization tests for the legacy code, if they exist) to confirm no regressions.
Step 7: Document the Refactoring Debt
ACTION: Add an entry to refactor-backlog.md immediately.
WHY: Sprout and Wrap are *intentionally temporary*. They leave old code in limbo — the source method or class has not been cleaned up, its responsibilities are now split, and the design is arguably worse than a proper refactoring would achieve. Documenting the debt ensures future work on this area includes a plan to get the source class under test and integrate the sprouted/wrapped logic properly.
Entry format:
## [ClassName / method] — Sprout/Wrap debt
Technique applied: [Sprout Method | Sprout Class | Wrap Method | Wrap Class]
New code location: [method or class name]
Source method/class: [name] in [file path]
What still needs doing: Get [SourceClass] under test, inline [NewMethod/NewClass] into proper location, eliminate the split responsibility.
Date introduced: [today]
Inputs
| Input | Required | Description | |-------|----------|-------------| | Source class and method | Yes | The legacy code where new behavior must appear | | New behavior description | Yes | What the new code must do (specific enough to test) | | Temporal coupling answer | Yes | Must new behavior fire on every existing call? | | Constructor testability answer | Yes | Can source class be instantiated in a test harness quickly? | | Test framework | Yes | Must be configured to run tests on new isolated code |
Outputs
| Output | Description |
|--------|-------------|
| New method or new class | The new behavior, fully tested in isolation |
| Modified source method | One-line integration call added (Sprout) or rename+delegate (Wrap) |
| refactor-backlog.md entry | Tracks the remaining design debt |
| Test file | TDD tests for the new method/class |
Key Principles
dispatchPayment(), uniqueEntries(), QuarterlyReportTableHeaderProducer — not payOld() or doWork2(). The sprout or wrap will likely persist longer than you expect.Examples
Sprout Method: Duplicate-entry detection in TransactionGate.postEntries() (Java)
Situation: postEntries(List entries) posts dates and adds entries to a bundle. A new requirement: skip entries already in the bundle. Adding the check inline mingles duplicate-detection with date-posting in one loop.
Analysis: Method-level + independent → Sprout Method.
Before (inline attempt — avoided):
public void postEntries(List entries) {
List entriesToAdd = new LinkedList();
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
if (!transactionBundle.getListManager().hasEntry(entry)) { // new check mixed in
entry.postDate();
entriesToAdd.add(entry);
}
}
transactionBundle.getListManager().add(entriesToAdd);
}
This mingles two operations: date-posting and duplicate detection. It also introduces a temporary variable that will attract more code.After (Sprout Method):
// New sprouted method — fully tested in isolation
List uniqueEntries(List entries) {
List result = new ArrayList();
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
if (!transactionBundle.getListManager().hasEntry(entry)) {
result.add(entry);
}
}
return result;
}// Source method: single integration call added
public void postEntries(List entries) {
List entriesToAdd = uniqueEntries(entries); // Step 6: uncommented
for (Iterator it = entriesToAdd.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
entry.postDate();
}
transactionBundle.getListManager().add(entriesToAdd);
}
uniqueEntries() is tested with a FakeListManager before the call in postEntries() is uncommented.Wrap Method: Payment logging in Employee.pay() (Java)
Situation: pay() calculates timecard totals and dispatches payment. New requirement: log every payment. Logging must happen every time pay() is called.
Analysis: Method-level + temporally coupled → Wrap Method.
Before:
public void pay() {
Money amount = new Money();
for (Iterator it = timecards.iterator(); it.hasNext(); ) {
Timecard card = (Timecard)it.next();
if (payPeriod.contains(date)) {
amount.add(card.getHours() * payRate);
}
}
payDispatcher.pay(this, date, amount);
}
After (Wrap Method — rename + delegate):
// Original logic, renamed, made private — Preserve Signatures applied
private void dispatchPayment() {
Money amount = new Money();
for (Iterator it = timecards.iterator(); it.hasNext(); ) {
Timecard card = (Timecard)it.next();
if (payPeriod.contains(date)) {
amount.add(card.getHours() * payRate);
}
}
payDispatcher.pay(this, date, amount);
}// New public entry point — callers are unchanged
public void pay() {
logPayment(); // new behavior — TDD'd in isolation
dispatchPayment(); // delegate to original
}
private void logPayment() { ... } // TDD'd, tested independently
All existing callers of pay() continue to work. The two behaviors — logging and dispatch — are independently testable.Sprout Class: HTML table header in QuarterlyReportGenerator (C++)
Situation: QuarterlyReportGenerator::generate() builds an HTML report. New requirement: add a header row to the HTML table. The class is a large legacy class that would take a day to get into a test harness.
Analysis: Class-level + independent → Sprout Class.
New class developed with TDD:
class QuarterlyReportTableHeaderProducer {
public:
string makeHeader();
};string QuarterlyReportTableHeaderProducer::makeHeader() {
return "
Department Manager "
"Profit Expenses ";
}
Integration into source method (uncommented after TDD passes):
// Inside QuarterlyReportGenerator::generate()
QuarterlyReportTableHeaderProducer producer;
pageText += producer.makeHeader(); // Step 6: uncommented
QuarterlyReportTableHeaderProducer is tested completely independently of QuarterlyReportGenerator. The legacy class is not touched beyond the one integration line.
Design note: The class name initially seems disconnected. Over time it can be renamed QuarterlyReportTableHeaderGenerator and unified under an HTMLGenerator interface — but that refactoring happens later, when the source class is finally brought under test.
References
Full step-by-step mechanics for all four techniques, including Sprout Class (6 steps) and Wrap Class (4 steps + Decorator pattern guidance):
references/four-techniques-mechanics.mdLicense
This skill is licensed under CC-BY-SA-4.0. Source: BookForge — Working Effectively with Legacy Code by Michael C. Feathers (2004, Prentice Hall), Chapter 6.
Related BookForge Skills
Dependencies (must be installed for full value):
legacy-code-change-algorithm — The 5-step framework that leads to this skill. Sprout/Wrap is used at Step 5 when you can't break enough dependencies upfront. IF not installed → use this skill standalone, but know that you are skipping test point identification and dependency analysis.safe-legacy-editing-discipline — The 4 safety constraints (Preserve Signatures, Single-Goal Editing, Hyperaware Editing, Lean on the Compiler) that govern Step 4's rename operation. IF not installed → apply Preserve Signatures manually: copy-paste signatures verbatim, make zero other changes during the rename step.Cross-references:
characterization-test-writing — When the source class finally gets under test (Step 7's future work), use this to write characterization tests that lock in its current behavior before you clean up the sprout/wrap debt.dependency-breaking-technique-executor — When you try Sprout Method but discover the source class can't be instantiated even for a method-level test, this skill applies the full catalog of 24 dependency-breaking techniques to make the class testable.seam-type-selector — Helps identify which kind of seam (object seam, link seam, preprocessing seam) is available in the source class; useful before choosing between Sprout Method and Sprout Class.Install the full book skill set: bookforge-skills — working-effectively-with-legacy-code
⚡ When to Use
💡 Examples
Sprout Method: Duplicate-entry detection in TransactionGate.postEntries() (Java)
Situation: postEntries(List entries) posts dates and adds entries to a bundle. A new requirement: skip entries already in the bundle. Adding the check inline mingles duplicate-detection with date-posting in one loop.
Analysis: Method-level + independent → Sprout Method.
Before (inline attempt — avoided):
public void postEntries(List entries) {
List entriesToAdd = new LinkedList();
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
if (!transactionBundle.getListManager().hasEntry(entry)) { // new check mixed in
entry.postDate();
entriesToAdd.add(entry);
}
}
transactionBundle.getListManager().add(entriesToAdd);
}
This mingles two operations: date-posting and duplicate detection. It also introduces a temporary variable that will attract more code.After (Sprout Method):
// New sprouted method — fully tested in isolation
List uniqueEntries(List entries) {
List result = new ArrayList();
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
if (!transactionBundle.getListManager().hasEntry(entry)) {
result.add(entry);
}
}
return result;
}// Source method: single integration call added
public void postEntries(List entries) {
List entriesToAdd = uniqueEntries(entries); // Step 6: uncommented
for (Iterator it = entriesToAdd.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
entry.postDate();
}
transactionBundle.getListManager().add(entriesToAdd);
}
uniqueEntries() is tested with a FakeListManager before the call in postEntries() is uncommented.Wrap Method: Payment logging in Employee.pay() (Java)
Situation: pay() calculates timecard totals and dispatches payment. New requirement: log every payment. Logging must happen every time pay() is called.
Analysis: Method-level + temporally coupled → Wrap Method.
Before:
public void pay() {
Money amount = new Money();
for (Iterator it = timecards.iterator(); it.hasNext(); ) {
Timecard card = (Timecard)it.next();
if (payPeriod.contains(date)) {
amount.add(card.getHours() * payRate);
}
}
payDispatcher.pay(this, date, amount);
}
After (Wrap Method — rename + delegate):
// Original logic, renamed, made private — Preserve Signatures applied
private void dispatchPayment() {
Money amount = new Money();
for (Iterator it = timecards.iterator(); it.hasNext(); ) {
Timecard card = (Timecard)it.next();
if (payPeriod.contains(date)) {
amount.add(card.getHours() * payRate);
}
}
payDispatcher.pay(this, date, amount);
}// New public entry point — callers are unchanged
public void pay() {
logPayment(); // new behavior — TDD'd in isolation
dispatchPayment(); // delegate to original
}
private void logPayment() { ... } // TDD'd, tested independently
All existing callers of pay() continue to work. The two behaviors — logging and dispatch — are independently testable.Sprout Class: HTML table header in QuarterlyReportGenerator (C++)
Situation: QuarterlyReportGenerator::generate() builds an HTML report. New requirement: add a header row to the HTML table. The class is a large legacy class that would take a day to get into a test harness.
Analysis: Class-level + independent → Sprout Class.
New class developed with TDD:
class QuarterlyReportTableHeaderProducer {
public:
string makeHeader();
};string QuarterlyReportTableHeaderProducer::makeHeader() {
return "
Department Manager "
"Profit Expenses ";
}
Integration into source method (uncommented after TDD passes):
// Inside QuarterlyReportGenerator::generate()
QuarterlyReportTableHeaderProducer producer;
pageText += producer.makeHeader(); // Step 6: uncommented
QuarterlyReportTableHeaderProducer is tested completely independently of QuarterlyReportGenerator. The legacy class is not touched beyond the one integration line.
Design note: The class name initially seems disconnected. Over time it can be renamed QuarterlyReportTableHeaderGenerator and unified under an HTMLGenerator interface — but that refactoring happens later, when the source class is finally brought under test.