DEV Community

Cover image for The Case Against Rewrites: How I Evolved a 5-Year-Old Backend Codebase (And Why You Probably Shouldn’t Rewrite Yours Either)
Olusola Ojewunmi
Olusola Ojewunmi

Posted on

The Case Against Rewrites: How I Evolved a 5-Year-Old Backend Codebase (And Why You Probably Shouldn’t Rewrite Yours Either)

A deep dive into modernising legacy systems incrementally without breaking production or losing years of edge-case history.

1. Most Legacy Systems Don't Fail Because of Bad Code. They Fail Because of Emotional Decisions.

Let's start with an uncomfortable truth: when your massive, tangled production backend starts slowing your team down, the code isn't your biggest enemy. Your ego is. And right behind your ego are the twin sirens of software engineering: the Rewrite Temptation and the Microservices Hype.

When we stare down the barrel of a 5-year-old backend — one that has survived pivoting business models, changing teams, and feature-factory panic — the instinct is always to burn it down and start over. We want a clean slate. We want the architectural purity we didn't have the time or experience to build five years ago. We tell ourselves it will be faster because "this time, we know the domain." We think that handing the entire business logic over to a constellation of managed cloud services, serverless functions, and shiny new vendors will somehow absolve us from having to manage complexity.

But here is the reality: production pressure doesn't stop just because engineering wants to refactor. If you tie your modernisation strategy strictly to a completely new rewrite or deep vendor lock-in, you are essentially betting the business on an unproven abstraction while starving your current customers of new value.

We didn't rewrite our 5-year-old backend. We evolved it. We deliberately avoided heavy vendor lock-in. We used AI agents not to write the code for us, but to act as archaeologists, dramatically accelerating our understanding of forgotten modules. We flirted heavily with microservices — and even paused the initiative because the organisational timing was undeniably wrong.

This isn't theory. This is the battle-tested playbook for incrementally modernising a growing, breathing monolith — without breaking the clients that depend on it.


2. What a 5-Year Backend Actually Looks Like

If you've never inherited or maintained a system that has been running and evolving for half a decade, the concept of "technical debt" can sound sterile and abstract. In reality, a 5-year backend is less like a poorly built bridge and more like an old city built on top of its own ruins.

When this system started half a decade ago, it was built by a small team operating with an early-career-level understanding. The goal was finding product-market fit, not establishing pristine domain-driven design.

As a result, pattern drift is everywhere. Look closely at the codebase, and you can see exactly when different paradigms were popular. You'll find early controllers that contain 800 lines of raw HTTP requests mingled with database queries and business logic. A year later, there's an attempt at the Repository Pattern — but it was half-abandoned, leaving a mix of raw ORM calls and over-engineered repository wrappers.

Accidental complexity runs rampant. In the critical paths — like order generation or payment processing — the coupling is terrifyingly tight. Fixing a bug in the notification dispatch system inexplicably breaks the user authentication flow because five years ago, someone needed the user's role and bypassed the boundary to fetch it directly from a deeply nested user relationship graph. There are models with 40 attributes and God Classes that serve as a dumping ground for anything tangentially related to a "User."

The standards are inconsistent, but the system works. It makes money. It serves users. You cannot disrespect the code, because the code got you here.


3. The Rewrite Temptation

In the face of mixed abstraction levels and tightly coupled core domains, the call to rewrite the system from scratch is deafening. A rewrite feels clean. It promises an end to the spaghetti code, the legacy bugs, and the cognitive overhead of parsing a giant monolith.

But a rewrite is dangerous, and quite frequently, it is a trap.

The hidden opportunity cost of a rewrite is immense. For every month you spend rebuilding existing functionality in a "cleaner" way, your product stagnates. Competitors move forward. The business grows impatient. But the technical risks are even worse: regression risk and knowledge evaporation.

That messy, 800-line function you loathe? It contains five years of hard-won edge-case handling. When you throw away the code, you throw away the business history written into it. The correct safety net for a rewrite is not a high code coverage number — a test suite can achieve 100% line coverage and still miss critical edge cases entirely if its assertions are weak. Coverage measures execution, not correctness. What you actually need is a comprehensive suite of behaviour-level tests that capture the observable outputs of every critical path. Practically speaking, that suite doesn't exist in most legacy codebases, which means a rewrite almost certainly guarantees that you will reintroduce old bugs.

Take a firm stance: unless the underlying technology stack is entirely deprecated, unsupported, or fundamentally incapable of scaling to projected usage, you must resist the rewrite. Evolve the system instead. Respect the edge cases.


4. The Microservices Attempt That Stalled

We didn't just consider rewriting; we aggressively pursued decomposition into microservices. Splitting these domains into discrete services seemed like the obvious, "senior" thing to do.

When the idea was first floated, I consulted a mentor with significantly more architectural experience. He explicitly warned against it: microservices weren't necessary for our scale, and we should simply embrace gradual refactoring over time. I didn't take that advice then — though I desperately wish I had.

And then, we stopped.

It wasn't a technical failure; it was a reality check. The key driver for the architecture change — yours truly — fell critically ill and took an extended leave of absence. Suddenly, the complex orchestration fell onto a team that was already trying to stay afloat. When I returned, feature backlog pressure from the business was crushing. I needed to ship value, not wire up Kubernetes clusters.

We realised a critical lesson: Architecture must survive absence. Microservices require organisational readiness and depth — your infrastructure maturity and team structure must match the architecture. Ours didn't yet.

So we paused the distributed microservices dream. Instead, we shifted our strategy to a Modular Monolith, as I was initially advised by my mentor. The goal was to set and improve our internal boundaries inside the existing codebase first — and then extract services later, only if and when the team and infrastructure were ready.


5. The Incremental Modernisation Framework

This approach mirrors what Martin Fowler describes as the Strangler Fig pattern — incrementally replacing legacy behaviour by building the new system around and alongside it, rather than in a single "big bang" replacement. The name comes from the strangler fig tree, which grows around a host tree over time until the host is fully replaced. Applied here, every modernised module is a new ring of growth around the legacy core, with the old code gradually hollowed out and replaced rather than demolished all at once.

I adopted a highly tactical blueprint built around this philosophy. It is designed to be executed incrementally, meaning feature development never has to fully stop.

Step 1: System Mapping Before Touching Code

Before you write a single line of refactoring, you must map the territory. We used dependency tracing to identify the deeply coupled domains — the modules where a change in one place sent shockwaves into three others.

Step 2: Behaviour Locking

You cannot safely change what you cannot test. We introduced what Michael Feathers, in his landmark book Working Effectively with Legacy Code, calls characterisation tests — tests written not to verify correctness, but to document and lock the current, actual behaviour of legacy code before you move it. We locked the behaviour so that as we started moving walls around, we would instantly know if we broke a load-bearing structure.

Step 3: Domain Isolation

With tests in place, we began domain isolation. This didn't mean moving code to a new server; it meant moving it to a clear, isolated namespace within the monolith with strict boundaries. Concretely, this meant:

  • Namespacing by domain (e.g., App\Domains\Orders, App\Domains\Payments) rather than by technical layer.
  • Banning cross-domain Eloquent relationships. If the Orders domain needed user data, it called a dedicated UserReadService interface — it did not reach across and eager-load a $order->user->wallet->transactions chain. Violations were caught in code review.
  • Using internal domain events to communicate state changes across module boundaries, laying the groundwork for potential future extraction without tight coupling.

To illustrate how this changed the code, here is how our OrderController evolved.

Before (V1): The God Controller

public function store(CreateOrderRequest $request): JsonResponse
{
    try {
        $order = $this->orderService->createOrder($request);
    } catch (Throwable $e) {
        return response()->error('Error occurred while creating order');
    }

    try {
        // Tight coupling to the Wallet/Ledger domain — reached directly across boundaries
        $charge = $this->orderService->walletCharge($order->user_id, $order->price, $order);
        $paymentComplete = data_get($charge, 'status');
        $remainingDebit = data_get($charge, 'remaining_debit');
    } catch (Throwable $e) {
        return response()->error('Error occurred while charging wallet');
    }
    // ... matching payment channels and long response logic ...
}
Enter fullscreen mode Exit fullscreen mode

After (V2): The Delegating Controller & Isolated Service

class OrderController extends Controller
{
    public function store(UnifiedCreateOrderRequest $request): JsonResponse
    {
        try {
            // The controller's only job is HTTP input/output.
            // All business orchestration lives in the service layer.
            $result = $this->unifiedOrderCreationService->createOrder(
                $request->validated(),
                auth()->user()
            );

            return response()->success([
                'order'   => new OrderResource($result['order']),
                'payment' => $result['payment'],
                'pricing' => $result['pricing'],
            ], 'Order created successfully.');
        } catch (OrderCreationException $exception) {
            // Map domain exceptions to safe, user-facing messages.
            // Raw exception messages are never exposed to API consumers
            // to avoid leaking internal implementation details.
            return response()->error('Unable to process your order. Please try again.', 422);
        } catch (Throwable $exception) {
            Log::error('Unexpected order creation failure', [
                'user_id' => auth()->id(),
                'error'   => $exception->getMessage(),
            ]);
            return response()->error('An unexpected error occurred. Please contact support.', 500);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The payment and pricing logic — previously reached by crossing domain boundaries directly in the controller — now lives entirely within UnifiedOrderCreationService, which coordinates between App\Domains\Orders and App\Domains\Payments through their defined interfaces.

Step 4: Database Evolution Strategy

One of the most practically challenging aspects of this kind of modernisation is the schema itself. We handled this carefully across two modes:

Where the existing table structure was close enough, we extended it with new columns via additive migrations — never dropping or renaming existing columns before both V1 and V2 consumers were confirmed to be off the old fields. This expand-then-contract approach meant V1 and V2 endpoints could coexist on the same data without a risky simultaneous cutover.

Where the old schema was fundamentally at odds with the new domain model, we created new tables and models entirely, wrote dual-write logic during the transition period, and deprecated the old table once traffic had fully migrated. This was cleaner than trying to contort an existing structure and avoided the risk of a failed migration taking down V1 in the process.

Step 5: Opportunistic Refactoring

We implemented the "Touch → Improve" rule. We didn't carve out entire sprints for tech debt. Instead, if an engineer had to touch a massive God Class to add a feature, they were expected to leave it slightly better than they found it — extracting one method, adding one test, removing one dead branch.

Step 6: Versioned Endpoints Strategy

To avoid breaking consumers — mobile apps, web clients, third-party integrations — we preserved all V1 endpoints exactly as they were. When a domain was fully isolated and tested under the new architecture, we introduced V2 endpoints with the clean interface. Consumers migrated on their own timelines.


6. Using AI as a Code Archaeologist

AI is not an architect. It will not magically refactor a coupled monolith into clean domains. What it provides is an unparalleled acceleration in the comprehension phase — the part that usually takes senior engineers days of careful reading.

I used AI as a Code Archaeologist. When facing a 1,200-line controller handling dynamic pricing variations that no one had touched in three years, a human engineer would spend most of a day just mapping the variable states and tracing the execution paths. Instead, I would feed the code into an AI agent workflow — using Windsurf and GitHub Copilot for in-editor analysis, and constructing detailed, structured archaeological prompts in ChatGPT and Gemini — asking the agent to trace the data flow, identify unintentional duplication, and document the implicit business rules buried inside conditional logic. It surfaced hidden business rules and flagged dead variable paths in a fraction of the time a full human review would require.

This heavily assisted my safe refactoring process. But I remained rigorous about validation. AI acts as an incredibly smart pair programmer who completely lacks institutional context. In one case, an agent confidently asserted that a variable was unused — missing an indirect call via PHP's dynamic reflection that would have caused a production failure if I had acted on it blindly. Every AI output was treated as a strong hypothesis to be verified, not a conclusion to be merged.

The ultimate architectural judgment — what to abstract, what to leave alone, what the business actually needs — must remain in the hands of the senior engineer.


7. What the Git History Revealed

If you want to understand the true state of your architecture, don't look at the codebase today. Look at the git history.

In the Early Stage (Year 1), the commits were enormous: "Added the custom response macros, needed libraries, exception handlers, models, and migrations." No boundaries. Entire domains were slammed into the monolith in single commits. The git log reads like a construction site — fast, functional, and completely undisciplined.

In the Mature Stage (Recent Commits), the inflexion points are unmistakable: "feat: implement ops authentication with role-based access control." Commit hygiene improved significantly. Class sizes stabilised. Domain-scoped changes stay within their namespaces. You can quantify the maturity of an engineering culture solely through log analysis — and what you find will either give you pride or a very clear improvement agenda.


8. Avoiding Vendor Lock-In

One of the great modern traps is coupling your architecture to proprietary services. We deliberately kept business logic portable. We treated cloud providers as commodity compute, not as an extension of our domain logic. Queues, storage, and caching were accessed through abstraction layers rather than vendor-specific SDKs embedded throughout the codebase.

Modernisation should increase your portability, not decrease it. If switching your queue provider requires changes in 40 files, your modernisation has made you more fragile, not less.


9. Measurable Improvements

The results were concrete and trackable:

  • Defect rate on order operations dropped significantly. Once the new domain boundaries were locked in and characterisation tests were in place, the number of bugs originating from order-related operations dropped dramatically. The few issues that did surface were rapidly isolated — the clear boundaries meant the blast radius of any problem was immediately obvious, and fixes were targeted rather than speculative.
  • Reduced cognitive load. I went from being terrified to touch the pricing module to confidently building and shipping new enterprise pricing tiers.
  • Faster onboarding. Clearer domain boundaries mean a new engineer can understand a specific module without needing the full five-year context of the system.
  • Performance gains without rewrite sprints. Opportunistic refactoring during feature work yielded significant performance improvements on critical APIs — without a single dedicated "performance sprint."

10. Hard Lessons

I will be completely candid: not everything was a success.

  • Unnecessary refactors. I cleaned up legacy corners that had a defect rate of zero and hadn't been modified in three years. I wasted cycles making "ugly" code look pretty. If it works and rarely changes, leave it alone. The Boy Scout Rule applies to code you touch — not code you stumble past.
  • Premature abstractions. I built generic interfaces for future data models that never materialised. Abstractions are liabilities until they are proven by actual use cases.
  • AI oversight. The reflection call incident above was a real near-miss. AI output requires the same scrutiny as a pull request from a brilliant engineer who has never read the product requirements. Review it accordingly.

11. Redefining "Modern"

Modern does not equal rewrite.
Modern does not equal distributed microservices.
Modern does not equal the latest buzzword.

Modern means Observability, Replaceability, and Clear Boundaries — whether those exist across Kubernetes clusters or across namespaces in a well-structured PHP monolith.

The Reality Check

Let me be completely clear: our evolved system is not a flawless utopia. We still encounter bugs. We still hit unexpected performance bottlenecks as the user base grows. Code still rots if left unattended.

The profound difference today is not the absence of problems — it's our approach to solving them. When a critical bug surfaces now, we don't panic or threaten to nuke the entire module out of frustration. We write a failing test to lock the behaviour, isolate the boundary, and refactor the specific bottleneck. We have successfully replaced emotional, short-term reactions with disciplined, long-term engineering maturity.

That, more than any architectural pattern or tooling choice, is what a successful modernisation actually looks like.


12. Discussion

Evolution almost always beats ego-driven rewrites. But these questions don't have universal answers — they depend on team, scale, and context. I'd love to hear how other engineers navigate them:

  • When is the exact right moment to finally rewrite instead of evolve?
  • When is an organisational structure truly ready for microservices?
  • What does your git history say about your engineering team's growth?

Drop your experiences below. The hardest lessons are the ones we shouldn't have to learn in isolation.

Top comments (0)