skip to content

You must restructure code that has no tests and cannot cheaply get any. What disciplines make editing safe(r) without a safety net, and how do you sequence a large legacy restructuring across a team?

level: principalimportance: nice to knowfreq 30%

answer

  1. Restrict the class of edit, do not rely on care
  2. Automated refactorings; preserve signatures; move don't retype
  3. Compiler is blind to reflection/serialization/DI names
  4. Scratch refactoring to learn, then revert
  5. Mikado + branch by abstraction + strangler + parallel run

basics

~20 s

Only make edits a tool or the compiler can verify: automated refactorings, preserving method signatures, moving code without retyping, one goal per edit, small steps compiled and run often. For big restructurings, work in small always-shippable increments, route new work through a new implementation, and keep the old path live until traffic is moved.

solid answer

~60 s

Without tests, safety comes from restricting the *kind* of edit rather than trusting care. Feathers' practices: prefer IDE-performed automated refactorings and verify the tool actually does what you think; preserve signatures so parameters cannot be silently reordered or dropped; move code by cut-and-paste rather than retyping; lean on the compiler (rename to force errors, remove a member to find users) while remembering it cannot catch reflection, dynamic dispatch, serialization or string-keyed lookups; one goal per editing session; hyper-aware review and pair programming; scratch refactoring — restructure aggressively to understand, then throw it away. Preparatory refactoring keeps the change and the cleanup separate. At scale, sequence with the Mikado Method (attempt the goal, note what breaks, revert, fix prerequisites bottom-up) and ship via strangler-fig: put a facade in front, route slices to the new implementation, run old and new in parallel with comparison where correctness is critical, and remove the old path only after traffic and data are migrated. Governance matters: every touched change point gets characterization tests so the untested region shrinks monotonically.

go deeper

for a junior

Name the mechanical safety rules: use IDE refactorings, keep signatures unchanged, move code instead of retyping, small steps, compile and run often.

for a middle

Add single-goal editing, pairing, scratch refactoring for comprehension, and preparatory refactoring committed separately from behaviour changes.

for a senior

Discuss compiler blind spots (reflection, DI-by-name, serialization, ORM mappings, external callers) and sequencing techniques — Mikado, branch by abstraction, strangler fig — with revertability as the criterion.

for a principal

Frame it as portfolio risk: coverage-on-changed-lines ratchets, parallel runs as production-fidelity characterization, funded deletion of old paths, and an explicit, evidence-based bar for choosing rewrite over incremental migration.

## Why 'be careful' is not a strategy Hand editing untested code fails because human attention is unreliable and the feedback loop is absent. The remedy is to change the **class of edits** you permit yourself: an edit whose correctness follows from how it was performed does not need a test to justify it. ## Disciplines for safe editing without tests **1. Automated refactorings, trusted but verified.** IDE operations (Rename, Extract Method, Move, Inline, Change Signature) apply mechanical transformations across the codebase. They are far safer than manual edits, but not infallible: some IDE refactorings change behaviour in edge cases (e.g. extracting code that captures a variable, or renames that collide with reflective/string-based lookups). Feathers' advice: know which refactorings your tool performs safely, avoid combining several before checking, and never accept a refactoring you did not read the preview of. **2. Preserve signatures.** When extracting or moving code, keep parameter lists identical and pass whole parameter objects. The classic error — swapping two same-typed arguments — is invisible to the compiler and to review. If the signature never changes, that error cannot occur. **3. Move, do not retype.** Cut and paste bodies; retyping introduces transcription errors that look plausible. **4. Lean on the compiler — and know its blind spots.** In statically typed languages you can weaponize the compiler: rename a member to a nonsense name to enumerate all users, or make a field private to find leaks. But the compiler cannot see reflection, dependency-injection wiring by name, serialization formats, ORM column mappings, dynamic proxies, string-keyed configuration, or callers in other repositories. Enumerate those explicitly before relying on "it compiles". **5. Single-goal editing.** One purpose per session. Mixing cleanup with a behaviour change means that when something breaks you cannot bisect intent — and reviewers cannot either. **6. Pair programming / hyper-aware review.** Two people watching a mechanical transformation is the cheapest substitute for a test. **7. Scratch refactoring.** When you cannot understand the code, refactor it aggressively — rename, extract, delete branches — purely to *learn*, then `git reset --hard`. Value is the understanding, not the diff; because it is thrown away, risk is zero. **8. Preparatory refactoring.** Restructure to make the intended change easy, then make the easy change, in separate commits ("make the change easy, then make the easy change"). This keeps every commit reviewable and revertable. **9. Provisional safety nets when tests are impossible.** Even without unit tests you can often add: a golden-master over the batch output, a canary on production metrics, a feature flag, or shadow/parallel execution comparing old and new results. These are coarse, but far better than nothing. ## Sequencing a large restructuring **Mikado Method.** Attempt the goal naively; observe what breaks; **revert**; record the prerequisite as a node in a graph; recurse. You end with a dependency graph whose leaves are small, independently shippable changes, and you build upward. The disciplined revert is the point — the repository is never left in a broken half-migrated state. **Branch by abstraction.** Introduce an abstraction over the thing being replaced, migrate callers to it, implement the new version behind it, switch, then delete the old implementation. Everything happens on trunk in small merges, avoiding a long-lived branch that diverges. **Strangler fig.** Put a facade/router in front of the legacy component. Route a slice of behaviour to a new implementation; grow the routed set; delete the legacy path when its traffic reaches zero. Naturally incremental and always revertable at the router. **Parallel run / shadow comparison.** For high-stakes logic (pricing, tax, risk), run old and new for the same input, serve the old result, and log discrepancies. This creates a characterization test from production traffic — the most faithful possible input distribution — at the cost of double compute and care with side effects (the shadow path must not write or charge). **Explicit deletion step.** Migrations that never delete the old path leave permanent double maintenance. Make removal a scheduled, tracked deliverable with traffic-based exit criteria. ## Organisational disciplines - **Ratchet, do not crusade.** Coverage-on-changed-lines as a gate makes the untested region shrink monotonically without funding a rewrite; a whole-codebase coverage target does not. - **Boy Scout rule with limits.** Leave code better than you found it, but cap incidental scope so reviews stay readable and revert stays possible. - **Do not rewrite from scratch by default.** A rewrite discards embedded knowledge — years of edge cases and bug fixes — and requires the old system to stop changing, which it will not. Rewrites are justified mainly when the platform is unsupportable or the domain has genuinely changed. - **Read-only phase first.** Before any edit, spend explicit time on comprehension: effect sketches, notes on the mess, and scratch refactorings. Cheaper than a wrong restructuring. ## Edge cases - **Dynamic languages** lose the compiler crutch entirely; type annotations plus static analysis partially restore it, and characterization tests become correspondingly more valuable. - **Reflection/DI-heavy frameworks** silently break on rename — grep for the class/method name as a string before renaming. - **Serialized and persisted data** encode structure: renaming a field can break stored records, message payloads, or database mappings, and those failures appear long after deploy. - **Cross-repository callers** are outside the compiler's view; treat them as a public API with deprecation windows.

  • Why is 'revert after every probe' central to the Mikado Method rather than fixing forward?
    Because it keeps the repository always in a known-good state and forces prerequisites to become explicit, independently shippable nodes. Fixing forward from a naive attempt produces a large, entangled, unreviewable change that cannot be paused, split across people, or safely abandoned.
  • When is a full rewrite defensible over incremental strangling?
    When the platform is unsupportable (unbuildable toolchain, dead runtime, unpatchable security), when the domain requirements have genuinely changed so preserving current behaviour has little value, or when the system is small enough that the rewrite is shorter than the migration. Otherwise a rewrite discards years of encoded edge cases and races against a moving target.
  • A parallel run shows 0.4% discrepancies between old and new pricing. How do you decide whether to cut over?
    Classify every discrepancy before deciding: some will be new-code bugs, some will be legacy bugs the new code fixed, some will be legitimate rounding or ordering differences. Cut over only when each class is explained and the business has explicitly accepted the intentional differences — an unexplained residual is a defect, not noise.
  • Which single practice most reduces risk when you must hand-edit untested code?
    Preserving signatures while moving code without retyping, performed through automated refactorings. It eliminates the whole family of silent errors — reordered or dropped arguments, transcription slips — that neither the compiler nor a reviewer reliably catches.

It is demolition inside an occupied building: you cannot switch the power off, so you use jigs and templates for every cut (automated refactorings), keep the load-bearing wall until the new frame carries the load (strangler fig), and take exploratory core samples you then patch over (scratch refactoring).

saying these in an interview costs you the question

  • Saying 'just be careful and review well' as the primary safety mechanism
  • Treating a successful compile as proof of correctness in a reflection-, DI- or serialization-heavy codebase
  • Mixing a behaviour change with a large refactoring in one commit
  • Keeping scratch-refactoring output because it 'looks better' — it was never validated
  • Long-lived refactoring branches instead of branch-by-abstraction on trunk
  • Strangler migrations with no funded deletion step, leaving two systems forever
  • Proposing a full rewrite as the default answer to a messy but working system

context