Design: Legacy Code Seams
From Working Effectively with Legacy Code (Feathers): legacy code is code without tests. The dilemma is circular — to change it safely you need tests, but to test it you often need to change it. The way out is to find a seam (a place where behaviour can be altered without editing in-line) to break the dependency that makes the code untestable, then pin the current behaviour with characterization tests before you touch logic.
The sequence is non-negotiable: get it under test first, change it second. Characterization tests document what the code does (not what it should do), so a refactor that preserves behaviour stays green and a behaviour change shows up as a deliberate, reviewed diff.
When to Use This Skill
| Use this skill when... | Use something else instead when... |
|---|---|
| About to modify untested code and want a safety net first | The code already has good tests → code-quality-plugin:code-review |
| A dependency (DB, clock, network, global) blocks unit testing | Judging test quality of existing tests → code-quality-plugin:code-test-quality |
| Pinning current behaviour before a risky refactor | A large multi-phase refactor → workflow-orchestration-plugin:workflow-checkpoint-refactor |
| Breaking a hidden dependency to inject a fake | Diagnosing a live bug → code-quality-plugin:debugging-methodology |
Core Principle
| Seam type | Where you alter behaviour | Typical use |
|---|---|---|
| Object seam | Override a method / inject a collaborator | Replace a real dependency with a fake (most common) |
| Link seam | Swap a library/module at build/link time | Stub a third-party at the boundary |
| Preprocessing seam | Macro/build substitution before compile | Last resort in C/C++-style builds |
Two enabling moves when no seam exists yet: Extract & Override (pull the hard-to-test work into a method, subclass in the test to override it) and Parameterize Constructor/Method (pass the dependency in instead of constructing it inside). Both are tiny, behaviour-preserving, and create the seam the test needs.
Parameters
Parse $ARGUMENTS:
- Target (optional, first positional) — a file, class, function, or diff to
bring under test. If absent, default to the current change (
git diff HEAD+ staged) and say so.
Execution
Execute this get-under-test workflow:
Step 1: Find the change point and its dependencies
Locate where the behaviour change is needed. List the dependencies that make it hard to test now — constructed-inside collaborators, global/singleton state, clocks, randomness, I/O, network, static calls.
Step 2: Find or create a seam
For each blocking dependency, identify the cheapest seam:
- An existing object seam (already-injectable collaborator, overridable method)
- Parameterize Constructor/Method — pass the dependency in
- Extract & Override — move the hard call into an overridable method
- A link seam when you genuinely can't touch the callee
Make only the smallest behaviour-preserving edit needed to open the seam. Note
that the seam you introduce is often an Adapter (see
software-design-plugin:design-patterns).
Step 3: Write characterization tests
Pin the current behaviour, not the intended behaviour:
- Write a test that calls the code and asserts whatever it currently returns.
- Run it; if it fails, change the assertion to match actual output (you are documenting reality, not judging it).
- Add cases until the branches you're about to touch are covered. A surprising characterized behaviour is a finding — flag it, don't silently "fix" it yet.
Step 4: Now change, then report
With the net in place, make the intended change; the characterization tests that should still hold stay green, and the ones that intentionally change become a reviewed diff. Report: the change point, the seam(s) opened (type + the minimal edit), the characterization tests added, and any surprising current behaviour the characterization surfaced.
Anti-patterns
| Mistake | Correct approach |
|---|---|
| Refactoring untested code "carefully" by hand | Get it under test first — careful is not a safety net |
| Writing tests for what the code should do | Characterize what it does; behaviour change comes after |
| A giant rewrite to make one method testable | Smallest seam-opening edit (Extract & Override / Parameterize) |
| "Fixing" a surprising behaviour mid-characterization | Flag it; change behaviour as a separate, deliberate step |
Quick Reference
| Blocker | Seam move |
|---|---|
| Collaborator constructed inside | Parameterize Constructor |
| Hard call buried in a method | Extract & Override |
| Global / singleton / clock | Inject via an object seam |
| Untouchable third-party | Link seam / Adapter at the boundary |
| Need to pin behaviour | Characterization test asserting current output |
Related
code-quality-plugin:code-test-quality— once under test, judge whether those tests are good (this skill only gets them present)software-design-plugin:design-patterns— the seam you open is often an Adapter; Extract & Override leans on subclassingworkflow-orchestration-plugin:workflow-checkpoint-refactor— the multi-phase refactor this safety net unblockscode-quality-plugin:debugging-methodology— a characterization test is also a reproduction harness for a legacy bug