Engineering decisions
Rewrite or refactor: how to judge a codebase you inherited
Refactor, unless you can name the specific architectural constraint that refactoring cannot reach. That rule survives almost every codebase you’ll be handed. In practice you usually want neither one wholesale: you strangle the system route by route behind the front door it already has, and you ship the entire time. A full rewrite is not a technical decision. It is a decision to stop shipping product for somewhere between six months and two years while you rebuild features your competitors already have.
Before anyone argues about it, measure. Five signals, one working day, from a clean checkout on a machine that has never seen this project: dependency drift weighted by blast radius, where the business logic physically lives, coverage weighted by what it covers, whether the build reproduces, and whether the schema has stopped moving. The argument is normally unwinnable because nobody has measured the thing being argued about.
Stabilise before you assess
If the previous developer has gone, do this first. It isn’t the assessment. It’s making sure there’s something left to assess.
- Prove you can deploy, meaning a one-character change reaches production and can be reverted.
- Restore a backup into a scratch environment and open the application against it. An untested backup is a belief, not a backup.
- Rotate credentials, registrar and DNS first: days to recover, hours to lose.
- Inventory cron jobs, queues and inbound webhooks. Undocumented cron is where legacy systems keep their secrets.
Signal one: dependency drift, weighted by blast radius
Everyone counts outdated packages. The count is close to meaningless. What matters is which of them sit on the path of authenticated requests or untrusted input.
Run npm outdated or composer outdated for drift, then npm audit or pip-auditfor known vulnerabilities. Now discard everything that isn’t one of four things: the language runtime, the web framework, the authentication or session library, and anything that parses input you didn’t write. That last category means image processing, XML, PDF, archive extraction and deserialisation.
Those four categories are the assessment. A build tool eleven versions behind is an afternoon. An unsupported runtime is a different class of problem: Node 18 left maintenance in April 2025 and Node 20 followed in April 2026, PHP 7.4 stopped receiving security fixes in late 2022, Python 3.8 in October 2024. Check the vendor’s own support table rather than trusting this paragraph, because those dates move and this article doesn’t.
Our threshold: two or more majors behind on the framework plus an unsupported runtime plus a hand-patched fork vendored into the repository is genuine rewrite pressure. Any one alone is a scheduled upgrade. The fork is the part people underweight: somebody already tried to upgrade, hit a wall, and paved over it.
Signal two: where does the business logic actually live
This decides the outcome more than any other signal, and almost nobody measures it. Pick three rules that would cost real money if they went wrong, such as how an invoice total is calculated, who may see a record, and what happens when a subscription lapses. Then find every place each one is implemented.
A healthy codebase gives you one location per rule and a handful of call sites. A codebase in trouble smears it across a controller, a front-end component that re-implements it for the optimistic UI, a trigger nobody remembers writing, a nightly script that quietly corrects the other three, and a reporting query with the logic inlined in SQL.
One implementation is fine. Two is normal. Four or more, for a rule that moves money, means you can’t change that rule safely by any method, which is precisely the pain most people mistake for needing a rewrite. A rewrite reproduces the smear, because the smear came from delivery pressure rather than from the framework. Look especially for triggers and stored procedures: invisible to code search, they survive every application-layer rewrite, and they’re the usual reason a finished rewrite produces different numbers from the system it replaced.
Signal three: coverage weighted by what it covers
A coverage percentage tells you nothing about an inherited codebase. Eighty percent line coverage achieved by exercising getters is worth less than nine tests around the payment path. Measure it differently. Write down the ten invariants that must never break, things like totals reconciling, a user being unable to read another tenant’s data, and a refund never exceeding the original charge. Then break each one deliberately in the source, run the suite, and count the red tests.
Ten out of ten is a codebase you can refactor aggressively. Under three means you have no safety net whatever the badge says, and your first month is characterisation tests rather than changes. Mutation testing automates this (Stryker for JavaScript, PIT on the JVM, mutmut for Python, Infection for PHP) but point it at the money paths only. Across a whole legacy repository it produces a number so large and so slow nobody looks twice.
Absent tests, though, are not a rewrite argument. Tests are cheaper to add to a working system than to write against one that doesn’t exist yet, and a rewrite also starts at zero coverage, without the existing behaviour to compare against.
Signal four: does it build from a clean checkout
Take a machine with none of this project’s history on it. Clone, install, run, stopwatch. Cheapest signal in the set, and the most predictive of what the next twelve months feel like.
Under an hour from written instructions that turn out to be true: the previous team was competent and most of what looks like rot is surface. Under a day with guesswork: normal. More than two days, or only one person has ever made it run: treat every other estimate about this system as unreliable, because nobody has verified anything in isolation for a long time. Check for a committed lockfile that still resolves, base images pinned rather than latest, and secrets read from the environment rather than committed.
Then run git log --since=12.months --format=%an | sort | uniq -c. Two contributors is a risk you can plan around. One, who has left, is the actual emergency, and it’s organisational rather than technical.
Signal five: has the schema stopped moving
The data model is the part a rewrite can’t escape. Application code can go a route at a time. The database is either shared by whatever replaces it, or migrated in one frightening step.
Read the migration history for the last two years. Additive migrations, meaning new tables, nullable columns and indexes, tell you the model fitted the problem and the business grew into it. Repeated structural churn on the same core tables means it never fitted, and that’s the strongest rewrite signal in this article.
Then look for the smells: columns named notes2, a catch-all JSON column that has quietly become the real schema, missing foreign keys where relationships obviously exist, enumerations stored as free text with four spellings live in production. Run the orphan and duplicate counts before deciding. Low rates mean a sound model wearing a bad application, which is a refactor. If the data contradicts itself, rewriting the code above it changes nothing.
Reading the five signals together
| Signal | Refactor territory | Rewrite pressure |
|---|---|---|
| Dependency drift | Behind, but an upgrade path exists | Unsupported runtime plus vendored forks of core libraries |
| Logic location | One or two implementations per rule | Four or more, including database triggers |
| Invariant coverage | Seven or more of ten break a test | Under three, and the domain is undocumented |
| Clean build | Runs in under a day from written steps | Only one person has ever made it run |
| Schema stability | Additive migrations, referential integrity holds | Core tables restructured repeatedly, data contradicts itself |
The weighting is not equal. Signal five outranks the other four combined. Refactor when the data model is sound and the pain sits in the layers above it, which describes most inherited systems. Rewrite only when the constraint is structural and you can state it in one sentence without using the word messy.
Constraints that genuinely justify a rewrite
- A single-tenant data model where the contract you just signed requires per-tenant isolation at the storage layer. A schema property, not a code property.
- A platform with no upgrade path at all, because the vendor shut the product down rather than deprecated an API.
- A consistency model the stack cannot express: transactional guarantees the storage engine does not offer, at a volume it cannot serve.
- A licence you cannot ship under, discovered late.
Reasons that are not constraints
The code is ugly. The team doesn’t know this language. There are no tests. It uses jQuery. A new lead has arrived with opinions. Each is real friction, and none is a reason to stop shipping for a year. Which stack you standardise on is a hiring question, and it looks different depending on whether you build in-house, with an agency, or with freelancers.
Why a full rewrite is a decision to stop shipping
Now the unpopular part. The cost that matters isn’t engineering hours. It’s the feature freeze, charged in market position rather than in invoices.
The freeze is never clean either. What happens instead is dual maintenance: the old system keeps taking urgent fixes because customers still use it, every fix is re-implemented in the new one, and the target keeps moving. Parity also includes undocumented behaviours some customer has built a workflow around. Those aren’t in the specification. They’re in the old code, the artefact you decided not to read.
An illustration, with invented numbers, showing shape rather than any real project: a system holding roughly eight engineer-years of behaviour, rewritten by three people, doesn’t take eight months just because the team knows the domain. Call it fourteen to twenty months to parity with dual maintenance alongside, then ask what your competitors ship in that window. If the answer is nothing that matters, a rewrite may be affordable. Usually it isn’t.
It’s also why we’re careful about how rescue work gets contracted. A fixed price on a system nobody has read yet carries a buffer sized by the unknowns, and those mechanics are set out in fixed price versus time and materials.
Strangle it: route by route, behind the existing front door
The strangler fig pattern is the default for almost every system that passes signal five and fails one or two of the others. Put a routing layer in front of the existing application, then move one slice at a time behind it. The old system keeps serving everything you haven’t moved. Nothing freezes.
Put the seam in while it is boring
Week one is a reverse proxy in front of the current application, routing all traffic to it and doing nothing else. Nginx, a load balancer, an edge function: the implementation matters far less than the seam existing. If it can’t go in without breaking sessions, authentication or file uploads, you’ve learned something important cheaply.
Pick the first slice by risk, not by ambition
The first migrated route should be read-only, low-traffic and verifiable by eye. A public listing page. An internal report. The instinct is to start with checkout because checkout hurts most; resist it. The first slice exists to prove routing, deploys, observability and rollback somewhere that doesn’t take payments.
Share the database before you split it
The new code reads and writes the same database the old code does. This is the part purists object to and it’s what makes the pattern work: two applications, one source of truth, no dual-write bugs, no reconciliation job. Splitting the data store is a separate project needing its own justification. Deferring it is scope control, not debt.
Where this pattern stalls
Being straight about what we’re recommending: a strangler migration stalls once the easy routes are done and only the tangled ones remain, and running two systems in parallel taxes every change. Give each cutover a documented way back, a routing flag rather than a deploy, and keep the remaining routes on a visible list with owners and dates. A stalled strangler is worse than either pure option, because now you maintain both.
When you should hire nobody at all
Some inherited applications should be neither rescued nor rewritten, but replaced with something you buy.
If the system is a brochure site with a blog, a standard store without unusual fulfilment logic, or an internal form-and-table CRUD app, the market has products that do all of it, patched on somebody else’s Tuesday. Custom software earns its cost when the logic is genuinely yours, which means pricing nobody else uses, a workflow that is the actual business, and integrations no vendor supports.
The test is quick and uncomfortable. Write down three things this application does that an off-the-shelf product couldn’t. If you can’t fill all three lines, spend two weeks evaluating products before you spend a quarter on code. That advice costs us work and it’s still right. The exception with a hard deadline is mobile: an app pinned to an SDK the stores no longer accept is a date on a calendar, which is why we treat store policy as part of the plan in mobile app development rather than as a launch afterthought.
What this assessment does not tell you
The five signals measure the artefact. They don’t measure the knowledge that used to surround it, and that’s the real limitation of running this as a one-day exercise.
A codebase can score well on every signal and still be effectively unmaintainable, because the reasons behind its stranger decisions left with the people who made them. Why is that column never updated on a Friday? Which of two customer identifiers is authoritative? Code shows you what, never why, and a clean repository with no institutional memory can be a slower start than a messy one whose author still answers email. So buy time with the outgoing developer if any is available, even a few paid hours, and spend all of it on why questions. No static analysis substitutes for that call.
What a paid assessment looks like
Everything above is something you can run yourself, and if you have a competent engineer with a spare day, do. An outside pair of eyes earns a fee on the judgement calls. Someone has to say how bad the schema drift is, whether that vendored fork is survivable, and what order to migrate in.
The shape we already publish for work like this is a two-week readiness sprint, from $6,000 on our consulting page. The figures we publish, that one included, are estimates in US dollars, not quotations; the quote that follows scoping is fixed in writing before kickoff. That sprint is scoped around AI readiness rather than legacy assessment, but the engagement shape is the one this work wants: fixed length, fixed price, a written findings document you own outright, no obligation to continue with whoever wrote it. Ask for that shape, from us or anybody else. The deliverable is a document, not a proposal. The delivery that follows is ordinary web application engineering, and how we write up build decisions is visible in our published case studies.
If this is due diligence on an acquisition
Same five signals, different weighting. Schema stability and dependency drift matter most, because both become the buyer’s liability on day one. Add a licence inventory, confirmation that intellectual property is actually assigned by every contractor who touched the repository, and the git history, which shows whether the delivery pace being claimed matches the commits. Ask for full history rather than a squashed snapshot. A seller unwilling to provide it has answered the question.
The compressed decision
Stabilise access and backups first. Run the five signals in a day and write the numbers down, because an unmeasured argument about a codebase is a preference contest. Refactor when the data model is sound, which it usually is. Rewrite only when you can state the constraint in one sentence. Otherwise do neither wholesale: put a router in front, move one low-risk route, keep the database shared, keep shipping. And if the thing turns out to be a product you could buy, tell whoever quoted you a rebuildthat you’ve found a cheaper answer.