DEV Community

Dakota Huang
Dakota Huang

Posted on

One Seam Per Patch: How to Measure the Smallest Safe Refactor

The smallest safe change is measurable, not a feeling. Three numbers define it.

  • seams — outside-world calls the patch moves, per entry point.
  • surface — exported names and signatures.
  • undo — commands needed to reverse the patch.

A patch qualifies when seams move by exactly one row, surface stays compatible, and undo is one command. Anything else is a rewrite wearing a small patch's clothes.

The counts below come from a five-order recording in a messy Python billing module. Your numbers will differ. The method does not.

0. Pin behavior before counting anything

Characterization tests come first. Record five real inputs, freeze the outputs to bytes, commit the goldens. No seam measurement happens until that suite is green twice on unmodified code.

That green pair is the baseline. Every later step is a comparison against it.

1. Name the seams by hand

Write the ledger yourself. No tool guesses your side effects for you.

# tests/seam_probe.py
SEAMS = {
    "db.load":     ("billing.repo", "load"),
    "clock.now":   ("billing.clock", "now"),
    "env.get":     ("billing.env", "get"),
    "http.post":   ("billing.gateway", "post"),
    "audit.write": ("billing.audit", "write"),
}
Enter fullscreen mode Exit fullscreen mode

Five rows is normal for a messy module. If you cannot list them, stop here. You do not know the code well enough to shrink it yet.

2. Make seams resolvable at call time (Patch 0)

The probe swaps module attributes. That only works when the caller looks attributes up late.

# breaks the probe: bound at import time
from billing.repo import load

# works: resolved on every call
from billing import repo
repo.load(order_id)
Enter fullscreen mode Exit fullscreen mode

Changing import style is a patch, so run the goldens after it. If bytes move, you found a shadowed name. That is a finding, not a refactor.

3. Record seam scores

from collections import Counter
import importlib

def count_seams(fn, *args, **kwargs):
    counts = Counter()
    applied = []
    for name, (mod_path, attr) in SEAMS.items():
        mod = importlib.import_module(mod_path)
        original = getattr(mod, attr)
        def wrapper(*a, _orig=original, _name=name, **kw):
            counts[_name] += 1
            return _orig(*a, **kw)
        applied.append((mod, attr, original, wrapper))
    try:
        for mod, attr, _orig, wrapper in applied:
            setattr(mod, attr, wrapper)
        result = fn(*args, **kwargs)
    finally:
        for mod, attr, original, _wrap in applied:
            setattr(mod, attr, original)
    return result, dict(counts)
Enter fullscreen mode Exit fullscreen mode

Run it across the same five recorded orders.

$ python -m tests.seam_probe --orders A-1,A-2,A-3,A-4,A-5 > seam_before.json
$ cat seam_before.json
{"quote": {"audit.write": 5, "clock.now": 5, "db.load": 5, "env.get": 5, "http.post": 3}}
Enter fullscreen mode Exit fullscreen mode

Five env reads for five orders. That is one seam read per call. Now you own a number you can move.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. The probe loop is the boring part of this workflow, so I use MonkeyCode's free model access to draft the wrapper and the ledger dict from a module listing, then edit both by hand. Generated seam lists miss indirect calls. The ledger stays a manual artifact and every patch still gets reviewed line by line.

4. Write the patch that moves exactly one number

Target: env.get goes 5 → 0. Everything else stays put.

# before
def quote(order_id):
    order = repo.load(order_id)
    tax_mode = env.get("TAX_MODE", "inclusive")
    ...

# after
def quote(order_id, *, tax_mode=None):
    order = repo.load(order_id)
    if tax_mode is None:
        tax_mode = env.get("TAX_MODE", "inclusive")
    ...
Enter fullscreen mode Exit fullscreen mode

Callers that pass tax_mode stop touching the environment. Callers that do not, behave exactly as before. Goldens must stay byte-identical either way.

Re-run the probe and diff the two files. One key changed. That is the whole acceptance argument.

$ python -m tests.seam_probe --orders A-1,A-2,A-3,A-4,A-5 > seam_after.json
$ diff <(jq -S . seam_before.json) <(jq -S . seam_after.json)
Enter fullscreen mode Exit fullscreen mode

5. Check the surface, not just the tests

Goldens prove behavior for five inputs. They say nothing about the signature you just widened.

# tests/surface_check.py
import inspect, json
from billing import price

def surface(mod):
    out = {}
    for name, obj in vars(mod).items():
        if name.startswith("_") or not callable(obj):
            continue
        try:
            out[name] = str(inspect.signature(obj))
        except (TypeError, ValueError):
            continue
    return out

if __name__ == "__main__":
    print(json.dumps(surface(price), indent=2, sort_keys=True))
Enter fullscreen mode Exit fullscreen mode

Rules are short. Added parameters must be keyword-only with defaults. Removed, renamed, or reordered positional parameters are a hard stop.

6. Rehearse the undo before you merge

A patch you cannot reverse is not small. It is unmeasured.

$ git switch -c refactor/one-seam
$ git apply --index patches/001-tax-mode-param.diff
$ python -m pytest -q            # goldens green
$ python -m tests.seam_probe > seam_after.json
$ git apply -R patches/001-tax-mode-param.diff
$ python -m pytest -q            # still green after reverse apply
$ git apply --index patches/001-tax-mode-param.diff
Enter fullscreen mode Exit fullscreen mode

Two green suite runs plus a clean reverse apply. If the reverse apply conflicts, you edited files outside the patch scope.

The before/after comparison has one soft spot: your shell. A stray TAX_MODE export changes both runs and hides the delta. MonkeyCode's free server option is where I run both passes now, on a machine with no project state in the environment. Same commands, cleaner comparison.

7. The gate table

Check Pass Fail
Files touched ≤ 2 3+
Public names unchanged any rename
Signature change keyword-only, defaulted positional or required
Seam deltas exactly 1 key 2+ keys
Goldens byte-identical any diff
Reverse apply clean, suite green conflict or red

Six rows, one page. Paste it into the pull request description and fill it in.

8. A patch I rejected last week

The diff added an LRU cache around repo.load. It also pulled clock.now out of the pricing path and threaded a timestamp through, and it renamed quote to quote_order.

Probe result: three seam keys moved at once. Goldens for order A-4 changed, because the cache key used the clock. Reverse apply was clean, which proves nothing about size.

Verdict: reject and split. First patch renames with quote kept as an alias. Second adds the timestamp parameter. Third adds the cache behind a flag. Three small patches beat one clever one.

Limitations

  • The probe sees attribute lookups, not C-level or framework magic.
  • Monkey-patching is not thread-safe; record serially.
  • Goldens only cover the inputs you recorded.
  • Counts describe call paths, not cost per call.

Who should not use this

  • Throwaway scripts under a few hundred lines. The ledger costs more than the refactor.
  • Code that binds seams at import time and cannot be changed safely. Fix the binding first or pick another target.
  • Wide mechanical renames across dozens of files. Use a codemod with its own gate.
  • Teams without a suite they already trust. The revert rehearsal assumes green means something.

Start with one entry point and one seam. You will learn more from a five-order recording and a one-key diff than from a week of reading the module.

Top comments (0)