September 12, 2026
REFACTOR ONE SAFE SEAM AT A TIME

Lesson 53.1 gave you a ranked debt map. Now comes the part where most AI-assisted projects go wrong: the urge to hand the whole map to the model and say "clean it up." A full-codebase polish feels productive for an hour and becomes unreviewable for a month. This lesson teaches the opposite move — one bounded structural improvement, with behavior pinned down, that a reviewer can actually approve.
Refactoring changes structure, not behavior
Refactoring means changing the internal structure of code while preserving its observable behavior. The user sees the same screens, the API returns the same contract, the database holds the same rules. Only the inside got easier to change.
That sentence carries a hard boundary. If the user-visible behavior changes too — a new date format, a stricter validation message, a faster-but-different refresh — that is a feature or a fix traveling alongside the refactor. Name it separately, test it separately, and review it separately. "Refactor plus small improvement" is how behavior breaks inside an unreadable diff.
A seam is the exact place where you will cut: the boundary between the code that moves and the code that stays. Good seams sit where a contract already exists or should exist — between a route and a parser, between a page and a policy helper, between a provider's vocabulary and your app's vocabulary (the adapter idea from Class 46.2). Bad seams are "everything in this folder." If you cannot name the seam in one sentence, the task is too big.
The controlled sequence
Run every refactor in this order. It is the debugging loop's calmer sibling:
seam → checks → contract → move → tests → diff → commit → docs
1. Seam. Choose one cut from TECH-DEBT.md. Example: "provider-specific date parsing inside the brief route." 2. Checks. Write or confirm the behavior checks *before* moving code. What proves today's behavior? Existing tests, plus a fixture or two for the tricky inputs. 3. Contract. State the no-change promise in writing: inputs, outputs, error behavior, and what the UI and API must still return byte-for-byte or field-for-field. 4. Move. Make the smallest structural move that honors the contract. Extract, rename, or relocate — one verb, not three. 5. Tests. Run the checks plus the relevant integration or preview check. A green unit test alone does not prove the route still enforces ownership. 6. Diff. Read the entire diff yourself. Every changed line must belong to the stated seam. 7. Commit. One refactor, one commit, with the invariant in the message. 8. Docs. Update the architecture or contract note only if the boundary actually changed.
Skip the checks and you cannot tell a preservation from a break. Skip the diff read and AI's "helpful" extras ride in silently.
A safe example: dates move to providerAdapter.ts
Take the debt-map classic: provider date parsing duplicated in three places, with the brief route doing the most parsing inline. The seam is clean — dates come in as provider strings, go out as your app's ResearchBrief.date field.
The no-change contract reads: "providerAdapter.ts accepts the same raw provider payloads the route accepted, returns identical ResearchBrief date fields for all fixtures, returns the same error for unparseable input, and changes no API field, no UI text, and no ownership behavior."
Concretely:
- Create
lib/providerAdapter.tswith one function, e.g.normalizeBrief(raw), handling every provider date variant found in the three copies. - Build fixtures for each variant actually seen in production or logs — ISO strings, Unix seconds, missing timezone, absent date — plus the failure case. Fixtures are controlled sample payloads (Class 50.1) that make the check fast and repeatable without calling the live provider.
- Point the brief route at the adapter. Leave the other two copies alone for now — one seam per commit. Keep the UI untouched; it consumes
ResearchBrief, not provider dialects. - Run the fixture checks, the route tests, and a preview of the brief page. Confirm the rendered brief is identical.
What you did *not* do matters as much: no renaming of API fields, no new date display format, no "while I'm here" ownership tweak, no dependency upgrade in the same diff.
No wide rewrites — use the Strangler beside-old pattern
Four prompts must never become a task card: "clean up the codebase," "convert all components," "replace the architecture," and any mass reformatting or dependency sweep bundled with logic changes. Each hides meaningful changes inside noise and makes rollback all-or-nothing.
When a large old area genuinely must evolve — say, a 600-line brief page — use the Strangler pattern in its lightest form: build the small new path *beside* the old one, route one behavior through it, prove it, then retire the old path deliberately.
Old page (600 lines, untouched, still serving)
→ new BriefCard component beside it (renders one fixture)
→ route one section through BriefCard, preview, compare
→ route the rest, test, then delete the old code
At no point is the app half-migrated and unshippable. Each step is a reviewable commit with a working product on both sides. The old path dies by explicit deletion, not by neglect — so no dead branch lingers for a future agent to resurrect.
Write the card that protects the seam
Every refactor gets a task card (Part X) before any model touches code. Copy this shape:
# TASK-CARD — Extract provider date parsing to adapter
## Invariant (behavior that must not change)
ResearchBrief date fields, API shapes, UI text, ownership
decisions identical for all fixtures. No new formats.
## Seam
Move date parsing from api/brief route → lib/providerAdapter.ts.
One function: normalizeBrief(raw).
## Allowed files
- lib/providerAdapter.ts (new)
- api/brief/route.ts (call-site only)
- tests/fixtures/provider-dates.* + adapter test
## Prohibited
- No UI changes. No API field renames. No validation tightening.
- No dependency updates. No touching watchlist or worker copies yet.
## Tests
- Adapter fixture test: all 5 date variants + failure case.
- Route test: brief payload identical before/after.
- Preview: /brief/AAPL renders unchanged (screenshot compare).
## Success metric
Duplication 3→2 copies; route drops ~40 lines; next provider
takes one adapter branch, not a route edit.
## Rollback
Revert commit <hash>; route is self-contained as before.
Notice the prohibitions do as much work as the permissions. An AI without a "do not touch" list will touch things.
Exercise: card one seam, ship one commit
1. Pick the smallest item from your TECH-DEBT.md — ideally under 60 lines of movement. 2. Write the card above with your invariant, seam, allowed and prohibited files, tests, metric, and rollback point committed to a branch first. 3. Run the sequence: checks → contract → move → tests → preview → diff read → commit → docs touch-up if the boundary moved.
Done means: one reviewable refactor commit on its own branch whose message states the invariant ("Extract date parsing; no behavior change; fixtures pass"). Verify: git diff --stat shows only the card's allowed files, and a second person can describe what changed without opening the code. Common failure: a 400-line "cleanup" diff with formatting churn — split it, revert the noise, and resubmit the seam alone.
Check your understanding
- What separates a refactor from a feature or fix, and why must they be reviewed separately?
- What does each step of seam → checks → contract → move → tests → diff → commit → docs protect?
- When is the Strangler pattern the right alternative to a rewrite?
Next you will zoom out: real cleanup also means dependencies, flags, credentials, docs, and ownership — and each of those gets the same card-and-review discipline.
ARTICLE DISCUSSION
JOIN THE
CONVERSATION.
Got a question, a take, or a better way to do this? Log in and leave a comment.
