Syntic

Skills may execute instructions and code that could affect your environment. Marketplace scans reduce risk but do not guarantee safety. Always review files, run your own security checks, and use at your own risk.

Business KnowledgeFree Safe

working-with-legacy-code

Security Scan Summary

Status: Safe

Source: Syntic Skills registry

Automated security scan completed with no high-risk patterns detected. Manual review is still required.

About This Skill

Use when safely changing or testing an untested codebase: seams, characterization tests, golden masters, sprout/wrap methods, or breaking dependencies to get legacy code under a test harness.

Downloadable SKILL.md

Download SKILL.md and place it in your Syntic skills folder. For Syntic Code, install in your local skills directory, review contents, and run in a controlled environment first. Acknowledge the risk notice above to enable the download.

SKILL.md
---
name: working-with-legacy-code
description: Use when safely changing or testing an untested codebase: seams, characterization tests, golden masters, sprout/wrap methods, or breaking dependencies to get legacy code under a test harness.
category: Business Knowledge
version: 1.0.0
tools: []
---

# Working Effectively with Legacy Code

A field manual for changing code that has no tests, distilled from Michael C. Feathers. Use it to get untestable classes into a harness, pin down current behavior with characterization tests, and make changes one safe, verifiable step at a time — without resorting to a rewrite.

## Core Principle

Legacy code is simply code without tests — not old code, not ugly code, but untested code. Without tests you cannot know whether a change preserves behavior, so every edit is a gamble. The craft is breaking dependencies just enough to get tests in place before changing anything — cover and modify, never edit and pray.

## Scoring

Goal: 10/10. Rate changes to untested code 0-10. 9-10: change points covered by characterization tests before any edit; behavior changes and refactoring shipped as separate verified steps; dependencies broken with the least invasive technique. 7-8: tests at most change points, but occasional mixed refactor-plus-behavior commits or heavier dependency surgery than needed. 5-6: some characterization tests, but key paths still changed on faith; sprouted code accumulating with no payback plan. 3-4: edit-and-pray with manual verification; tests written after the change, asserting whatever the new code happens to do. 0-2: untested edits straight into tangled code, refactoring and behavior change mixed in one commit, rewrite proposed instead of tests.

## 1. The Legacy Code Dilemma and Change Algorithm

The dilemma: changing code safely requires tests, but getting tests in place requires changing code. The way out is a fixed sequence — identify change points, find test points, break dependencies, write tests, then make changes and refactor — where pre-test edits are conservative and mechanical, and the real change happens only inside the safety net.

Key insights: there are two reasons to change code — changing behavior (feature, bug fix) and improving structure (refactoring) — mixing them in one step makes failures undiagnosable. Test points are rarely the change points: effects propagate, so you often test where the change's effects surface, not where the edit happens. Dependency-breaking edits made before tests exist must preserve signatures exactly and lean on the compiler to find every affected site. Coverage grows along the paths you actually change — that beats any dedicated "testing project" that never gets funded. "Programming is the art of doing one thing at a time": each step of the algorithm is separately verifiable.

Apply: for a bug fix in an untested module, pin the function with characterization tests before touching the bug. For a PR mixing cleanup and a feature, split into structure-only and behavior-only commits. For "it's just a one-line change," find the nearest test point first — one pin test at the public method that calls the private one you're editing.

## 2. Seams: Where to Pry Code Apart

A seam is a place where you can alter behavior without editing in that place. Every seam has an enabling point — where you decide which behavior runs. Getting legacy code under test is largely a hunt for seams: spots where a test can substitute a slow, global, or external dependency while production source stays untouched.

Key insights: object seams are the default in OO code — every overridable call is a seam, its enabling point wherever the object is created or passed in. Link and import seams swap implementations at build or load time — `jest.mock` and `unittest.mock.patch` are link seams in modern clothing. Preprocessing seams (C/C++ macros) are the bluntest instrument — reach for them last. `new Database()` inside a method body is a seam that never got built; constructors doing real work, globals, statics, and hard-wired I/O are where seams die. A seam without a reachable enabling point is useless. Dynamic languages make nearly every name lookup a seam — cheap, but patching internals couples tests to file layout.

Apply: a class that constructs its own DB client → object seam via constructor parameter (`constructor(db = new ProdDb())`, tests pass a fake). A module calling a top-level `send_email()` → import/link seam (`mocker.patch("billing.send_email")` or `jest.mock("./mailer")`). Logic reading the wall clock directly → inject a `now()` provider so tests can freeze time.

## 3. Characterization Tests

A characterization test documents what the code actually does right now, not what the spec, comments, or memory say it should do. Write a probe you know will fail, let the failure message reveal the real behavior, then change the assertion to pin that behavior in place.

Why it works: in legacy systems, actual behavior is the de facto spec — callers, reports, and customers may depend on it, quirks included. Characterization tests fail during refactoring precisely when and where you changed existing behavior.

Key insights: the recipe is call the code in a harness, assert something absurd (`expect(total).toBe(-1)`), read the failure, pin the observed value. Sensing and separation are the two reasons to break dependencies — separation gets code into a harness, sensing lets assertions see what it computed. For complex output (reports, generated files, large JSON) use a golden master: capture full output once, diff against it forever. Snapshot tests are golden masters — review the first snapshot like code and normalize volatile data, or you're pinning noise. If you find a bug while characterizing, pin it with a comment and a ticket rather than fixing it inline — downstream code may depend on the wrong behavior. Characterize only the branches your change will touch, not the whole system.

Apply: refactoring a tax calculator → run 20 representative cases through, assert each recorded result. A legacy report generator → golden-master diff against a checked-in master file. An off-by-one found while pinning → pin the wrong value with a comment (`assert days == 30  # BUG? expected 31 — TICKET-482`).

## 4. Sprout and Wrap: Changing Without Tests First

When an area genuinely can't be tested today, don't weave new logic into the untested mass. Sprout Method or Sprout Class: write new behavior as fresh, fully tested code, called from a single line in the legacy spot. Wrap Method or Wrap Class: rename the old code aside and add behavior before or after the call to it, decorator-style.

Why it works: new code in a fresh method or class can be test-driven even when its host can't be instantiated in a harness — testability no longer waits on getting the host under test. The untested host changes by exactly one call site, so the unverified blast radius is a single line instead of the whole method.

Key insights: Sprout Method when new logic plugs in at one point; Sprout Class when the host class won't even instantiate in a harness. Wrap Method suits behavior that surrounds old code (logging, notification, metering) rather than mixing with it — rename `pay()` to `rawPay()`, recreate `pay()` as the wrapper. Wrap Class is the Decorator pattern — use when several call sites need the added behavior or the class is already bloated. Be honest about the trade-off: the host stays untested. Track sprouts as debt and pay them back next time a change lands there.

Apply: a late-fee rule inside a 400-line `process()` → Sprout Method, one call line (`total += lateFee(order)`, `lateFee()` written test-first). Audit logging around legacy `pay()` → Wrap Method (new `pay()` logs, calls `rawPay()`, logs again). New validation where the class won't instantiate → Sprout Class.

## 5. Dependency-Breaking Techniques

A catalog of mechanical, low-risk moves that sever whatever blocks instantiation or sensing: Extract Interface, Parameterize Constructor, Parameterize Method, Extract and Override Factory Method or Getter, Introduce Instance Delegator, Adapt Parameter, Break Out Method Object, Subclass and Override Method. Because they run before tests exist, always pick the least invasive technique that unblocks you.

Key insights: Parameterize Constructor with a production default is the workhorse — existing callers compile untouched while tests inject fakes. Extract Interface is the safest move in the book — introducing an interface can't change behavior, only loosen a type. Subclass and Override Method underlies half the catalog — a testing subclass that stubs the dangerous parts is legitimate, not a hack. For statics and singletons, Introduce Instance Delegator hands callers an instance they can swap; a static setter can supersede a singleton in tests. Adapt Parameter beats fighting unfakeable framework types — wrap `HttpServletRequest` in a narrow interface and test against that. Dynamic languages have cheaper seams (`unittest.mock.patch`, `jest.mock`) that can stand in for several techniques, but parameterizing leaves better design behind.

Apply: a constructor that opens a DB connection → Parameterize Constructor (`def __init__(self, conn=None): self.conn = conn or connect()`). A static `Billing.charge()` called everywhere → Introduce Instance Delegator. A 900-line method hoarding locals → Break Out Method Object (`RateCalculation(order, rates).run()`, locals become fields).

## 6. Untangling and Understanding

Before changing code you don't understand, invest in cheap comprehension: effect sketches trace what a change can affect, feature sketches show how methods and fields cluster inside a god class, scratch refactoring means refactoring recklessly to learn and then throwing the edits away, and telling the story of the system forces a simplifying summary. The payoff is finding pinch points — narrow places where a few tests cover wide behavior.

Key insights: an effect sketch is a bubble per variable or method, an arrow per "affects" — trace forward from the change point to every place behavior can leak out. A pinch point is a narrowing in the effect sketch; test there and everything upstream is covered. Scratch refactoring is refactoring as a reading technique — extract, rename, and simplify for an hour, then revert; the insight survives the checkout. Monster method strategy: golden-master it at a pinch point, Break Out Method Object, then refactor inside the new class. God class strategy: feature-sketch the clusters, then extract along the natural boundaries between them.

## Common Mistakes

Edit-and-pray on untested code → substitutes care for feedback, which doesn't scale. Mixing refactoring with behavior change in one commit → makes failures undiagnosable. Testing after the change instead of before → the test only confirms whatever the new code does. Reaching for a full rewrite instead of characterization tests → discards working behavior nobody documented.

## Quick Diagnostic

Are change points covered by characterization tests before any edit? Are behavior changes and refactoring separated into distinct, verified steps? Was the least invasive dependency-breaking technique used? Were sprouted/wrapped additions tracked as debt with a payback plan? Were pinch points used to cover wide behavior with few tests?

## Source

Michael C. Feathers, *Working Effectively with Legacy Code*.

Bundle Download

Includes SKILL.md and bundled support files where provided. Risk acknowledgement is required.

Install Targets

Syntic App

  1. 1. Create a dedicated folder for this skill in your local skills library.
  2. 2. Place SKILL.md into that folder.
  3. 3. Restart Syntic and invoke this skill on matching tasks.

Syntic Code (CLI)

  1. 1. Save SKILL.md in your local Syntic Code skills directory.
  2. 2. Keep related files in the same skill folder.
  3. 3. Run in a safe environment and validate outputs.

Source

https://github.com/wondelai/skills/blob/main/working-with-legacy-code/SKILL.md

Open Source Link
Business Knowledge

Related Skills