skip to content

You inherit a business-critical, 6,000-line god class that every team edits weekly and that has almost no tests. How do you plan and sequence unwinding it without freezing feature delivery?

level: principalimportance: should knowfreq 28%

answer

  1. characterization tests before moving code
  2. churn + co-change data reveal boundaries
  3. extract cluster → delegate → ship
  4. strangler: new work only in new components
  5. ratchet guardrails + explicit ownership

basics

~20 s

Don't rewrite it. Pin current behavior with characterization tests, find seams where responsibilities cluster, extract one cohesive piece at a time behind a delegating façade, route new work to the extracted parts, and add guardrails so the old class cannot regrow.

solid answer

~50 s

Treat it as a migration, not a refactor sprint. First, get evidence: churn and coupling data to find which parts actually change, plus production usage to find dead paths you can delete outright. Second, get a safety net: characterization tests (asserting current behavior, not intended behavior) at the highest level you can afford, plus contract tests at its public surface; for risky paths use parallel-run/shadow comparison. Third, create seams — introduce interfaces per caller (Interface Segregation) so consumers depend on narrow contracts rather than the class. Fourth, extract cohesive clusters of fields+methods one at a time, leaving delegating stubs so no caller changes in the same commit; ship each step. Fifth, strangle: all new features go to the new components, so the god class shrinks by attrition. Finally, guardrails — size/complexity thresholds, architecture tests, and clear ownership — and track progress with objective metrics (lines, fan-in, churn, conflict rate, incident rate) so the effort is visible and can be paused safely at any point.

go deeper

for a junior

Say: don't rewrite; add tests that capture current behavior, then extract small pieces one at a time and keep delegating so callers don't break.

for a middle

Add characterization tests versus correctness tests, extracting by cohesive field/method clusters, keeping refactor and behavior commits separate, and adding lint guardrails.

for a senior

Bring churn/coupling analysis to choose boundaries, interface segregation per caller, sprout/wrap for new behavior, strangler routing with feature flags, and shadow runs for risky logic.

for a principal

Treat it as a funded migration: justify with cost-of-delay metrics, sequence by risk-adjusted value, align new module boundaries with team ownership (Conway), define ratcheted fitness functions, publish progress metrics, and state the stopping condition.

## Why this is a migration problem, not a cleanup task A long-lived god class is where a decade of undocumented business rules live. It is also a **coordination bottleneck**: every team edits it, so any long-running rewrite branch diverges faster than you can merge it. Both facts point to the same conclusion — proceed in **small, individually shippable, behavior-preserving steps** that can be interleaved with feature work and abandoned at any point without leaving the system worse. ## Step 0 — Decide whether to act at all Refactoring is an investment; justify it with the cost it removes: merge conflict rate, lead time for changes touching the class, defect density, onboarding time, inability to test. If the class is stable (low churn) and nobody is blocked, leaving it alone may be correct. Rank by *change frequency × complexity* — hot, complex code repays cleanup; cold complex code usually does not. ## Step 1 — Gather evidence - **Version-control churn analysis**: which methods/regions change, and together with what. Co-change clusters are natural module boundaries revealed by history rather than guesswork. - **Coupling map**: who calls in (afferent) and what it calls out (efferent). Callers define the interfaces you will need. - **Runtime usage**: log or trace which paths actually execute in production. Dead code is the cheapest win — delete it instead of refactoring it. - **Field-usage clustering**: group methods by the fields they touch; each cluster is a candidate extracted class. ## Step 2 — Build the safety net *before* moving code - **Characterization (approval/golden-master) tests** capture what the system *currently does*, including behavior you suspect is wrong. Their job is change detection, not correctness. Generate inputs broadly and snapshot outputs. - **Seam-level tests** at the class's public surface, ideally driven from real recorded traffic or production-shaped fixtures. - **Parallel run / shadow comparison** for the riskiest logic: execute old and new implementations side by side on real traffic, log differences, only cut over when divergence is zero. - **Feature flags / toggles** so each cut-over is reversible in seconds without a deploy. Without this, every subsequent step is a gamble; with it, extraction becomes mechanical. ## Step 3 — Create seams - **Interface Segregation**: give each caller group a narrow interface describing only what it uses. This decouples callers from the class as a whole and lets you re-point them later, one at a time. - **Dependency inversion**: replace internally constructed or globally located collaborators with injected ones, so pieces can be tested and replaced. - **Sprout and wrap** (from working-with-legacy-code practice): implement new behavior in a *new* class (sprout) or wrap the existing call to add behavior, rather than adding another method to the blob. ## Step 4 — Extract cohesively, ship continuously For each cluster: extract class → move the fields and methods → replace the original bodies with one-line delegations → ship. Callers keep compiling; behavior is unchanged; the diff is reviewable. Prefer **behavior-preserving, tool-assisted refactorings** (extract class, move method, inline) over hand-editing. Never mix a refactor commit with a behavior change commit — mixed diffs are unreviewable and un-bisectable. Order the clusters by risk-adjusted value: start with a moderately sized, well-understood cluster to prove the pipeline and build trust, not with the scariest core rule. ## Step 5 — Strangle Adopt the **strangler fig** approach: new features are implemented only against the extracted components; the façade routes calls to old or new implementations. The god class shrinks by attrition even during periods when nobody is funded to work on it. Eventually the façade has no logic left and can be deleted or demoted to a thin adapter. ## Step 6 — Guardrails so it does not regrow - Automated thresholds: file length, cyclomatic complexity, parameter counts, fan-in limits — with a **baseline** so existing debt does not block builds but new debt does (ratchet: the number may only go down). - **Architecture/fitness-function tests** encoding the target boundaries ("module X must not depend on Y"). - **Explicit ownership** for the new components; unowned code re-accretes. - Review norm: adding a method to the legacy class requires justification. ## Organizational realities - **Fund it visibly.** Purely opportunistic "boy-scout rule" cleanup rarely finishes a 6,000-line class; agree an explicit allocation (e.g. a fixed share of each iteration) with a stated business rationale. - **Team topology matters.** If five teams edit it, the class mirrors a missing ownership boundary (Conway's law). Extraction should follow the intended team boundaries, otherwise the new modules will just be co-edited too. - **Measure and publish**: lines in the class, number of dependents, merge-conflict count, change lead time, incidents attributable to it. Metrics turn "cleanup" into a trackable initiative and let leadership stop it rationally rather than emotionally. - **Know when to stop.** The goal is not zero legacy; it is removing the bottleneck. If churn and conflicts fall to acceptable levels with 1,500 lines remaining, declare victory. ## Why the big-bang rewrite is the wrong default It requires reproducing behavior you cannot enumerate, forbids incremental value delivery, forces a long-lived branch against a file everyone edits, and concentrates all risk into one cut-over. Rewrites are defensible only when the platform/runtime itself must change or the code is genuinely small; even then, prefer strangling behind a stable interface.

  • Why not just rewrite it cleanly on a branch?
    Because the class encodes undocumented rules nobody can enumerate, every team edits it weekly so the branch diverges continuously, no value ships until cut-over, and all the risk lands in one release. Strangling delivers value incrementally and stays reversible.
  • How do you make refactoring safe when there are no tests and you cannot easily write them?
    Use characterization tests at the coarsest reachable boundary (HTTP, message, or CLI level), replay recorded production inputs, and add shadow/parallel runs comparing old and new outputs on live traffic behind a flag. Pin behavior first, then move code.
  • How would you show progress to leadership?
    Track leading indicators tied to the pain: lines and dependents of the class, merge-conflict count, change lead time for features touching it, defect/incident rate, and share of new features implemented outside it. That lets the initiative be paused or stopped on evidence.

Replacing the load-bearing wall of an occupied building: you add temporary supports (tests and flags), build the new frame beside the old one, transfer load a little at a time, and only then remove the wall — you do not evacuate the tenants and demolish first.

saying these in an interview costs you the question

  • Proposing a big-bang rewrite as the default plan
  • Splitting by line count instead of by cohesive responsibility
  • Writing unit tests against private internals before extracting, then discarding them
  • Mixing behavior changes into refactoring commits, making review and bisect useless
  • Starting with the scariest core rule instead of proving the pipeline on a moderate cluster
  • No guardrails afterwards, so the class regrows within a year

context