DEV Community

Dakota Huang
Dakota Huang

Posted on

One Change, One Verify: Snapshot-Driven Refactoring for Legacy Code

One Change, One Verify: Snapshot-Driven Refactoring for Legacy Code

Legacy refactors fail when one change does too much. The fix is a snapshot, one edit, and a verify run. This recipe makes the smallest safe change measurable.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The failure mode

Most legacy refactors bundle five changes into one pull request. Renames, extractions, and logic tweaks mix together. When a test fails, nobody knows which edit broke it.

Characterization tests solve the detection problem. They lock in current behavior before you touch anything. But a full characterization suite takes days. You do not need days for a small refactor. You need a snapshot.

A snapshot is a small, recorded corpus of inputs and outputs. It is not a specification. It is a net that catches accidental drift.

Step 1: Snapshot current behavior

Pick one legacy function. Do not refactor it yet. Build a small corpus that covers normal values, zero, negatives, and boundary thresholds.

Here is a legacy function with hidden behavior:

# legacy.py
def apply_discount(price, code, member=False):
    total = price
    if code == "SAVE10":
        total = price * 0.9
    elif code == "SAVE20" and member:
        total = price * 0.8
    if total > 100:
        total -= 5
    return round(total, 2)
Enter fullscreen mode Exit fullscreen mode

The total > 100 adjustment is easy to miss. A snapshot records it as fact, not as intent.

# snapshot.py
import json
from legacy import apply_discount

cases = [
    {"price": 50, "code": None, "member": False},
    {"price": 120, "code": "SAVE10", "member": False},
    {"price": 120, "code": "SAVE20", "member": True},
    {"price": 120, "code": "SAVE20", "member": False},
    {"price": 0, "code": "SAVE10", "member": True},
    {"price": -10, "code": None, "member": False},
]

snapshot = []
for c in cases:
    snapshot.append({**c, "expected": apply_discount(c["price"], c["code"], c["member"])})

with open("snapshot.json", "w") as f:
    json.dump(snapshot, f, indent=2)
print(f"snapshotted {len(snapshot)} cases")
Enter fullscreen mode Exit fullscreen mode

Run it once. Commit snapshot.json before any refactor. The commit order matters: snapshot first, edit second, verify third.

Step 2: Rank the change before you make it

Not all edits carry the same risk. Score your intended change before writing code.

Change Risk Snapshot value
Rename a local variable Low Low
Extract a pure helper Low–medium High
Reorder conditions Medium High
Change rounding or types High Critical
Add caching or state High Critical

The smallest safe change is the lowest-risk edit that still moves the code toward your goal. Extraction usually wins. It preserves behavior and creates a seam for future tests.

Step 3: Make one edit

Extract the discount-rate logic. Change nothing else.

# legacy.py
def _discount_rate(code, member):
    if code == "SAVE10":
        return 0.9
    if code == "SAVE20" and member:
        return 0.8
    return 1.0

def apply_discount(price, code, member=False):
    total = price * _discount_rate(code, member)
    if total > 100:
        total -= 5
    return round(total, 2)
Enter fullscreen mode Exit fullscreen mode

One edit. One purpose. The behavior should be identical.

Step 4: Verify against the snapshot

Run the verify script. It compares every recorded case against current output.

# verify.py
import json
from legacy import apply_discount

with open("snapshot.json") as f:
    snapshot = json.load(f)

failures = 0
for case in snapshot:
    actual = apply_discount(case["price"], case["code"], case["member"])
    ok = actual == case["expected"]
    failures += 0 if ok else 1
    print(f"{'PASS' if ok else 'FAIL'} {case} -> {actual}")

print(f"{len(snapshot) - failures}/{len(snapshot)} passed")
exit(1 if failures else 0)
Enter fullscreen mode Exit fullscreen mode

Zero failures means the change is behavior-preserving. One failure means your edit moved a boundary. Inspect the failing case before deciding whether the new behavior is intended.

Where a free model fits

Generating the initial corpus is the boring part. A free model can propose edge cases from the function signature. I use MonkeyCode's free model access for exactly this step: it drafts candidate inputs, and I review them against the code. Free model access is a starting point, not a verdict. The model has not seen your callers or your domain rules.

The verify cycle also benefits from an isolated environment. MonkeyCode's free server option runs the snapshot and verify scripts without touching my local repo state. That keeps the experiment separate from real work. I treat the server as a scratch sandbox, not as a production runner. The recipe itself does not require it; a local virtualenv works fine.

Limitations

Characterization tests lock in bugs. If the original function has wrong behavior, the snapshot preserves it. That is the point: behavior preservation is a separate concern from bug fixing. Fix the bug in a second, explicitly labeled change.

This recipe does not cover performance refactors. A snapshot checks output, not speed. Use a benchmark for that.

It also fails on functions with heavy side effects. If the legacy code writes files or calls external services, snapshot the side effects too, or isolate them behind a seam first.

Who should not use this

Do not use this recipe if you are changing public contracts. Renaming a function or altering its signature is an API break. Snapshot tests will not save you from downstream callers.

Do not use it as a substitute for real tests. The snapshot is a net, not a specification. Add explicit unit tests once the refactor lands.

The rule

One change. One verify. Zero drift. That is the smallest safe change.

Pick one legacy function this week. Snapshot it. Make one edit. Measure the drift.

Top comments (0)