Skip to main content
ArticlesProjects

The Rewrite You Should Not Do

You have run the diagnostics and you have a list. Now price the three options honestly, including the one where you do nothing, which is right more often than the other two put together.

You have run the greps from the last six articles. There is a list now. The test suite is mostly database tests, one model is a junction for six features, the bulk tool and the endpoint disagree, and there is an interface in there that has never had a second implementation.

Somebody looks at the list and says what somebody always says. We should rewrite it.

This is the last part, so it is the one that has to answer the question the other six kept deferring: given that you can now see what is wrong, what do you actually do, and what does each option cost.

There are three, and the third is right more often than the other two put together.

The rewrite is not one of the three

I want to deal with the rewrite first so it is out of the way, because it will keep coming up otherwise.

Joel Spolsky wrote the definitive version of this in 2000, about Netscape, and the argument has not needed updating. The reason a rewrite is more expensive than it looks is not that the code is long. It is that the existing system contains knowledge that exists nowhere else.

Every strange branch in that model is there because something happened. A customer had a null where the schema said there could not be one. A payment provider sent a duplicate webhook on a Tuesday in 2023. VAT works differently for one class of customer and somebody found out the hard way. None of that is in the ticket system, none of it is in comments, and all of it is in the code as conditions that look like noise to anyone reading them fresh.

A rewrite discards that and rediscovers it, in production, one incident at a time. And it does so while delivering no value at all until the day it is finished, which is the part that kills the project politically long before it kills it technically.

The version of this that gets people is that the rewrite always looks cheap, because you are comparing writing the good parts of the new system against maintaining all of the old one. The comparison that matters is the whole new system, including the eighteen months of accumulated corrections, against the parts of the old one you actually touch.

There is a case where a rewrite is right, and it is narrower than people want it to be: the thing is genuinely small, or the requirements have changed so much that you are deleting more than you are rebuilding. If you are reimplementing the same behaviour with better structure, that is not a rewrite, that is a refactor with extra risk.

The real question is what you touch

Here is the measurement that should have come first, and I have saved it for last because it is the one that reorders everything else.

Coupling only costs you when you go near it.

A model with six reasons to change that nobody has edited in fourteen months is not costing you anything. It is ugly, it would score badly on every diagnostic in this series, and it is completely irrelevant to how fast you can ship. Meanwhile a mildly awkward class you edit twice a week is charging you rent every time.

So take the churn.

Terminal window
# what has actually changed in the last year
git log --since='1 year ago' --name-only --format='' -- app/ \
| grep . | sort | uniq -c | sort -rn | head -20

Now put that list next to the findings from the other six articles. The overlap is your entire backlog, in priority order, and it is usually much shorter than the findings list.

That is the whole method: fix where pain and frequency overlap, and leave the rest. Most of what is wrong with a mature codebase lives in parts nobody visits, and fixing those is work with no return.

Option one: strangle it

For the things that survive that filter, the move is the strangler fig, which Fowler named after the plant that grows around a tree until the tree is gone and the shape remains.

The new implementation goes up beside the old one, traffic moves across gradually, and the old one is deleted when nothing points at it. In Laravel you have a few places to put the seam.

At the route, which is the bluntest and safest.

Route::post('/orders/{order}/refunds', RefundController::class); // old
Route::post('/v2/orders/{order}/refunds', NewRefundController::class); // new

Or inside a single operation, behind a flag, which is what feature flags are actually for beyond marketing rollouts. I have written about Laravel Pennant separately, and this is its most valuable use.

public function handle(Order $order, int $amount, DateTimeImmutable $now): Refund
{
return Feature::active('refunds-v2')
? $this->newRefund->handle($order, $amount, $now)
: $this->legacyRefund->handle($order, $amount, $now);
}

The cost of this is real and people underestimate it in the opposite direction from the rewrite: while both paths exist, every change to refund behaviour has to be made twice or deliberately not made twice. That is a tax on the whole team for the duration, and the duration is always longer than planned because the last ten percent of traffic is the awkward ten percent.

Which gives you the one rule that makes strangling work: decide in advance what finishing looks like, and write it down. A percentage, a date, and a named person who deletes the old path. Without that you do not get a strangler fig, you get two implementations forever, which is strictly worse than either one on its own and is the most common outcome of this pattern in practice.

Option two: fix it in place

For a lot of what you found, the strangler is too much machinery.

Pulling a rule out of a controller, as in part one, is an afternoon. Moving an observer’s side effect to the call site is smaller. Deleting an interface with one implementation is a type hint change and a static analysis run. None of those need a parallel implementation, because none of them change behaviour, and that is the test: if the change is provably behaviour preserving, do it in place and let the tests hold you.

The trap is doing six of them at once, in a branch, for two weeks. That is a rewrite wearing a refactor’s clothes and it merges like one.

One rule, one commit, one deploy. The value of this work is almost entirely in the fact that it is reversible, and a two week branch is not reversible.

Option three: write it down and leave it

The one nobody counts as a decision.

If a piece of the codebase is unpleasant and rarely touched, the correct action is to record what is wrong with it and go and do something else. The friction is real but it is charged per visit, and if the visits are twice a year then the total is small enough that fixing it will never pay back.

What makes this a decision rather than neglect is the writing down. An ADR, a note in the repository, a comment at the top of the offending class that says what is wrong and what the trigger for fixing it would be.

# 14. Refund logic is duplicated between the endpoint and the bulk job
The window check exists in both. They currently agree.
Not fixing this now because bulk refunds run about four times a year and
the endpoint is stable. Revisit if a third entry point appears, or if the
refund window becomes configurable per market.

That costs ten minutes and it converts an unknown into a known with a trigger on it. The next person to look does not have to rediscover the problem or wonder whether anyone had noticed it. And when the third entry point does appear, the note tells them the decision was made deliberately and the conditions have now changed.

Most of what you found running the diagnostics in this series should end up here. That is not a failure of the series. It is the point of measuring rather than reacting.

What this series was actually about

Seven articles, and none of them told you how to structure a Laravel application.

That was deliberate, because the how is well covered and the deciding is not. What most codebases are missing is not a better pattern. It is a way of telling whether the pattern they have is costing them anything, and a way of saying no to work that will not pay back.

So the whole thing reduces to three questions, asked in this order.

Is this a real cost, and can I measure it? The suite time, the pair count, the fan-in, the number of entry points, the churn. If you cannot produce a number, you have a preference rather than a problem.

Has the trigger fired? A second caller, a second entry point, a second implementation, a second tenant. One instance never justifies an abstraction, no matter how uncomfortable it looks.

Do I go near it? Coupling in code you do not touch is free.

If all three answers are yes, fix it, in the smallest reversible piece you can. If any of them is no, write down what you found and leave it alone. That is a complete method, it fits in a paragraph, and it will save you more time than any architecture diagram you draw this year.

Part of a Series

Why Is This Hard To Change?

You are reading Part 7 of 7 in this learning series.

View Full Series

Share

XLinkedIn

Related

Keep Reading

All posts →