name: legacy-code description: Safely modify code that lacks tests. Use whenever tasked with changing code without test coverage — apply characterization tests and dependency-breaking techniques before making any behavioral changes. role: worker user-invocable: true
Legacy Code
Overview
Techniques for safely modifying code that lacks tests or has poor structure. Based on the principle that legacy code is code without tests (Michael Feathers' definition) — regardless of age. The goal is to get code under test before changing it, then improve structure incrementally.
Constraints
- Never change behavior and structure in the same step; refactor under green tests only
- Write characterization tests before modifying legacy code — not after
- Prefer the smallest dependency break that gets code under test
- Do not apply aggressive refactoring without test coverage
Core Concepts
Legacy Code Definition
Code without tests. A well-structured 6-month-old codebase with no tests is legacy. A messy 10-year-old codebase with comprehensive tests is not. The presence or absence of tests determines whether you can change code safely.
The Legacy Code Change Algorithm
- Identify change points — where in the code does the change need to happen?
- Find test points — where can you observe the effects of the change?
- Break dependencies — make the code testable without changing its behavior
- Write tests — characterization tests that lock down existing behavior
- Make changes and refactor — now that tests protect you, modify behavior and improve structure
Seams
A seam is a place where behavior can be altered without editing the code under test:
- Object seams — use polymorphism to substitute behavior (interfaces, subclasses)
- Link seams — substitute dependencies at the linking/import level (dependency injection, module mocking)
- Preprocessing seams — alter behavior via build configuration, feature flags, or compile-time substitution
Characterization Tests
Tests that document what the code actually does, not what it should do. They lock down existing behavior so you can detect unintended changes. Characterization tests answer: "If I make a change here, what breaks?"
Patterns
Characterization Test Procedure
- Find the code area you need to change
- Write a test that calls the code and lets it fail to reveal actual output
- Update the test assertion to match observed behavior
- Repeat until the change area and its immediate dependencies are covered
- Use these tests as a safety net — any behavioral change will now show as a test failure
Finding Test Points (effect reasoning)
Step 2 of the algorithm — where can you observe the effects of the change? — is its own skill in tangled code. Before writing tests, reason about effects and place verification where it has leverage. The full method (effect sketches, interception points, pinch points, and the editing-safety techniques — Lean on the Compiler, Preserve Signatures, Single-Goal Editing) is in knowledge/legacy-test-strategy.md. In brief:
- Trace effects outward from each change point; every place you can detect an effect is an interception point.
- Prefer a pinch point — a narrowing where one or two tests sense changes across many methods — to anchor characterization tests, then push verification down to narrow units once the area is malleable (don't let pinch-point tests calcify into mini-integration tests).
- Ask of any candidate: "If I break this method, will I sense it here?"
Dependency Breaking Techniques
The everyday shortlist below covers most cases. The full 24-technique catalog (globals/singletons, non-OO seams, hard parameters, monster methods, with seam type and risk for each) lives in knowledge/dependency-breaking-techniques.md — read it when a break here doesn't present a seam.
| Technique | When to Use | Risk |
|---|---|---|
| Extract Interface | Class has a concrete dependency you need to substitute | Low |
| Extract Method | Long method with embedded logic you need to isolate (safest starting point) | Low |
| Parameterize Constructor | Class creates its own dependencies internally | Low |
| Extract and Override Call / Factory Method | A specific call or new in a constructor blocks the test |
Low–Med |
| Subclass and Override Method | Need to neutralize or replace specific behavior in tests | Medium |
| Sprout / Wrap Method or Class | Adding new behavior to untested code (see decision below) | Low–Med |
| Adapt Parameter | Method depends on a type you can't use in tests | Medium |
Sprout vs. Wrap Decision
- Sprout (new method/class called from existing code): use when the new behavior is an addition that existing code needs to invoke at a specific point. The existing code changes minimally — it gains one call to the new method.
- Wrap (new code that calls existing code): use when existing callers must see the new behavior transparently without modification. The new code sits between callers and the original code.
Strangler Pattern
Incrementally replace legacy components by routing new behavior through new code while old code remains operational:
- Identify a bounded area of legacy functionality
- Build new implementation alongside the old
- Route new requests/features through new code
- Gradually migrate existing behavior to new code
- Remove old code only when fully replaced and verified
Output
Report the legacy code analysis: identified change points, test points, dependency breaks needed, characterization test targets, and the recommended refactoring sequence. Be concise — bullet list format; skip background explanation.
When to Apply
| Situation | Apply? |
|---|---|
| Modifying code without tests | Yes |
| Adding behavior to poorly structured code | Yes |
| Migrating legacy components to new architecture | Yes |
| Greenfield development | No |
| Code already well-tested and well-structured | No |
| Pure deletion of unused code | No |
Guidelines
- Never change behavior and structure in the same step. Refactor under green tests only.
- Write characterization tests before modifying legacy code. The tests document current behavior, not desired behavior.
- Prefer the smallest dependency break that gets code under test. Aggressive refactoring without test coverage creates risk.
- Sprout when the new behavior is clearly separable; wrap when existing callers must see the new behavior transparently.
- Every dependency break is temporary scaffolding — revisit and clean up once test coverage allows broader refactoring.
- If you cannot find a seam, extract method first. Extract Method is the safest starting point for almost any dependency break.
- Characterization tests are not a substitute for acceptance tests. They lock behavior; acceptance tests define behavior.
Integration
- Specs — when modifying legacy code to add new behavior, specify the new behavior first, then use this skill to get existing code under test before implementing
- Hexagonal Architecture — dependency breaking techniques move legacy code toward port/adapter separation incrementally
- Quality Gate Pipeline — verify characterization tests match observed behavior (Phase 1); legacy code changes have higher defect risk, apply review-correction with increased scrutiny (Phase 3)
- Mutation Testing — after writing characterization tests, use mutation testing to verify those tests catch behavioral changes
- Dependency-Breaking Techniques — the full 24-technique catalog (Feathers Part III) behind the shortlist above; consult for globals/singletons, non-OO seams, hard parameters, and monster methods
- Legacy Test Strategy — effect reasoning, effect sketches, interception/pinch points (where to test) and editing-safety techniques (how to edit before tests exist)
- Testability Patterns — constructor injection, Test Data Builder, interface extraction, the Design-for-Testability seam family (Humble Object, Dependency Lookup), and the "I Can't Test This Class" decision flow are the target design the dependency breaks scaffold toward; read it when the break needed is a design change rather than a temporary seam insertion