Refactoring a monolith you cannot stop shipping
The rewrite that loses a third of your features, the seams worth finding first, and why "delete the dead code" is the highest-value refactor nobody…
Every large codebase eventually reaches the point where a small change takes a week. The instinct at that point is to rewrite. I have done both — incremental refactors and full rebuilds — and the thing I wish I had understood earlier is that they fail in completely different ways.
A rewrite silently loses about a third of what you had
Not the features you talk about. The ones nobody wrote down: the redirect that keeps an old link working, the edge case in the export, the setting three customers depend on, the page that gets forty visits a month from a search result you forgot ranks.
Nobody notices for months, because the people who used those things do not file a ticket. They just stop using the product.
So when I do rebuild, the rebuild is gated on two artifacts written before any new code exists. A frozen public contract — every URL the old system serves, enumerated from the running system rather than from memory. And a parity ledger — one row per route, marked keep, merge, or drop, with the rule that a drop on an indexed URL is not allowed. Merge is fine; a merged feature keeps its address.
One row per route, not per feature. Features have gaps between them and routes vanish into those gaps.
Find the seams before you find the abstractions
For an incremental refactor, the useful question is not "what is the right architecture" but "where does this code already almost separate". Seams tend to be:
- Anywhere data crosses a boundary — an API response, a database row, a form submission.
- Anywhere a comment says "this is temporary".
- Anywhere two features share a file for no reason other than history.
Start there, because those changes are reviewable. A refactor whose diff cannot be read is a rewrite wearing a smaller hat, and it will be approved on trust rather than on inspection.
Delete first
The highest-value pass, and the one nobody schedules: remove everything that is not used. Unused exports, dead branches, components nothing renders, feature flags for features that shipped two years ago, commented-out code that someone was going to come back to.
It is high-value for a specific reason — every one of those is something a person has to read and decide about before they can change the thing next to it. Deleting a hundred lines that do nothing makes the surrounding hundred easier to reason about, at zero behavioural risk.
My rule is: unused code is deleted, not renamed with an underscore and not commented out. Version control is the archive. A file full of commented-out code is a file where nobody can tell what runs.
The one measure of a refactor I trust: can somebody who did not write it make a small change safely? Not "is it elegant" — elegance is not observable from outside.
Extract behind the boundary you already have
Once the dead weight is gone, the useful move is usually to give a tangle one door. Not to split it into six clean modules — to make everything outside it call one function instead of reaching into three.
The inside stays ugly for now, and that is fine. What changes is that the ugliness stops spreading, because nothing new can couple to internals it can no longer see. That is the whole benefit, and it is available immediately.
Constraints beat conventions
The lesson I have learned most expensively: a rule people are supposed to remember is a rule that decays. A rule the tooling enforces does not.
Every discipline in my codebases that actually survived is one that got a mechanism:
- "Do not fetch unbounded lists" became a helper whose limit is a required argument.
- "Do not use
console.log" became a lint rule at error level with one exempted file.
- "Every string goes through the translation layer" became an AST lint rule — because a hardcoded string passes typecheck, lint, build and any review reading for correctness.
- "The rebuild must match the approved design" became a test that parses the design's own manifest.
And each one gets watched failing before it is trusted. A gate nobody has seen go red is a gate nobody has verified — I have shipped a typecheck script that exited zero on genuinely broken code because the configuration compiled nothing at all. It reported safety it never checked, for weeks.
Working in a codebase you did not write
Most refactoring advice assumes you understand the system. The realistic case is that you do not, and the first job is to build a map cheaply without reading everything.
What works for me, in order. Start with the routes or entry points, because they tell you what the system does from outside, which is the only description guaranteed to be current. Then follow one feature all the way through — route, handler, data access, storage — rather than reading each layer horizontally. One vertical slice teaches you the conventions; four horizontal layers teach you four vocabularies with no idea how they connect.
Then read the version history rather than the code, for the file you are about to change. A file that changes every week is a hot spot and probably a seam; a file untouched for two years is either stable or abandoned, and the commit messages usually say which. This is the cheapest source of architectural information in any repository and almost nobody uses it.
The trap I would flag specifically: a recursive search from the top of a project can silently skip a subdirectory that is its own repository with its own ignore rules, returning zero results for a string the code plainly contains. That is worse than an error, because a "no references anywhere" sweep passes for the wrong reason and you delete something that is still used. If a search returns nothing at all, verify the search before believing the answer.
And write down what you learn as you go, in the repository rather than in a notebook. The map you build in your first week is the map the next person needs, and by month two you will not remember which parts were non-obvious.
What I would say to someone about to start
If you can improve it in place, do that. A rewrite is justified when the platform is genuinely dead, or when the product's shape has changed so much that the old model is actively wrong — not when the code is merely unpleasant.
And if you do rewrite: enumerate the contract first, from the running system, and treat that list as law. Everything else is recoverable. A URL that quietly stopped existing is not.
https://aoneahsan.com/blog/clean-code-refactoring-monoliths