DEV Community

Prithwiraj Das
Prithwiraj Das

Posted on

The Bugs Were Never in the New Code


When you add a feature to an old system, the new code is usually fine. The bugs come from the old code's assumptions, the ones nobody wrote down because back then they were just true.

I hit a small version of this recently. One feature, three bugs, all the same underlying thing. It wasn't until afterward that I realized I'd been running into this same pattern for years and had never written any of it down. The examples here are from a C# stack, but the shape is the same across the stacks I've worked in.

The setup

A multi-tenant platform. One screen had a long-standing way of doing something, gated behind some legacy config. We were adding a newer way to do the same thing, opt-in, for a subset of customers. So for a transition period there were two flows living side by side on the same screen.

This is a familiar shape if you've modernized anything live. You don't get to delete the old flow on day one. The old and the new have to coexist, and that coexistence is where the assumptions quietly pile up.

It shipped, and for most customers it worked. Then one slice of customers, the ones who had the new flow turned on and none of the old config, hit a blank screen on freshly created records. A refresh fixed it.

That last detail is the tell. When a refresh fixes it, the data is usually fine and the rendering path is where things went wrong. When I pulled the thread, it wasn't one bug. It was three, stacked on top of each other, and each one turned out to be the same mistake wearing different clothes.

Bug one: a sibling pretending to be a child

The new logic had been added inside the conditional that handled the old flow.

public ScreenData GetScreenData(Guid recordId)
{
    var legacyData = _legacyRepo.Fetch(recordId);
    var result = new ScreenData();

    if (legacyData != null)                       // gate for the OLD flow
    {
        result.Legacy = Populate(legacyData);

        if (_customer.HasNewFeature)              // NEW logic, nested inside
        {
            result.NewFeature = PopulateNewFeature(recordId);
        }
    }

    return result;
}
Enter fullscreen mode Exit fullscreen mode

Customers who had both flows were fine, because the legacy data existed for them. Customers who had only the new flow failed the outer check, the whole block was skipped, and the new logic nested inside it never ran.

Here is the part I want to be honest about. I read this code. It did not announce itself. Sitting next to the code it lived beside, nesting it was the natural-looking thing to do, and nothing on the page says it's wrong. The only way to see the problem is to already know there's a customer config where that outer condition is false, and that fact isn't written anywhere in the code. The fix is to make the new logic a sibling instead of a child:

public ScreenData GetScreenData(Guid recordId)
{
    var legacyData = _legacyRepo.Fetch(recordId);
    var result = new ScreenData();

    if (legacyData != null)
    {
        result.Legacy = Populate(legacyData);
    }
    else
    {
        result.Context = FetchContextDirectly(recordId);   // new-only path still works
    }

    if (_customer.HasNewFeature)                  // SIBLING: runs in both cases
    {
        result.NewFeature = PopulateNewFeature(recordId);
    }

    return result;
}
Enter fullscreen mode Exit fullscreen mode

Bug two: a heuristic that expired

The screen refreshed its content only when one specific field changed, because historically that field was the only thing the panel depended on.

Finding this one took a minute, because everything upstream looked correct. The API returned the right data. The debugger showed the right model. The values were all there, and the panel still sat empty until a manual refresh. That narrowed it to one place: something had stopped telling the panel it needed to redraw.

private void OnSave(IReadOnlyCollection<string> changedFields)
{
    if (changedFields.Contains(PrimaryField))     // the one thing it used to depend on
    {
        RefreshPanel();
    }
}
Enter fullscreen mode Exit fullscreen mode

The new feature introduced a second thing the panel now depended on, and the refresh logic knew nothing about it. So in the exact case where the new dependency changed but the original field didn't, the panel never refreshed. The check was correct the day it was written. It stopped being correct the moment the data started depending on something else.

I didn't re-plumb this one. It was a small feature already moving through the release pipeline, and rewriting the refresh path the right way wasn't something I could fit into that window. So it got a targeted fix and a note for later. That's the real version of this work: the right fix and the shippable fix aren't always the same one, and the feature in the pipeline doesn't wait for the cleanup you'd rather do.

Bug three: context that fell off a boundary

This is the one that's actually interesting.

The old framework had a built-in way to embed one piece of rendered output inside another. The new framework dropped it, so somewhere along the line it had been rebuilt by hand to keep the old views working. The rebuild rendered the embedded piece by spinning up a fresh, isolated sub-request.

In a multi-tenant system, "isolated" is the whole problem. The tenant context, the thing that tells the system whose data it's allowed to read, lived in the ambient state of the outer request. The sub-request didn't inherit it.

// Outer request: tenant is present in ambient scope
public IActionResult Render(Guid recordId)
{
    var tenant = _ambient.Tenant;                 // "customer 42"
    var data = _service.GetData(recordId);        // correctly scoped to customer 42
    var model = BuildModel(data);                 // controller has the RIGHT data

    // ...the hand-rolled embed helper re-renders a fragment:
    return EmbedFragment(model);
}

private IActionResult EmbedFragment(Model model)
{
    using var sub = _requestFactory.CreateNew();  // fresh scope, NO ambient tenant
    var data = _service.GetData(model.RecordId);  // scoped to nothing -> empty
    model.Fragment = data;                        // empty overwrites the correct data
    return View(model);
}
Enter fullscreen mode Exit fullscreen mode

So the right data was fetched, then thrown away and re-fetched in a context that no longer knew the tenant. The re-fetch came back empty and overwrote the good result with nothing. Blank screen.

The deeper thing underneath it: ambient context doesn't cross a boundary. The moment you open a sub-request, a thread, a background job, or a queue message, whatever was "just there" stops being there. That's the piece that came back on me later at a much bigger scale.

A request flowing along a track carries a

One mistake, three forms

It was only after the third one that I saw all three were the same shape. None of them were failures in the new feature. Each one came from something the old system had quietly depended on for years and never stated.

A branch nested too deep, a trigger scoped too narrow, a rebuild that lost its context. Underneath, the same thing: an assumption that was true in the old system and false in the new one, never written down because it never needed to be.

Three labeled bugs,

What AI did and didn't do

I planned this feature with AI in the loop, so it's worth being straight about where it helped and where it didn't.

My history with this work has three stages. For years I did it by reading code for days and holding the system in my head, and I still missed things, because nobody holds a whole system at once. Then AI became a faster lookup and a decent rubber duck. Now it's more than that: I hand it a scoped piece of discovery, get an analysis back, turn that into a plan, take the plan to the people who need to sign off on it, and execute. It doesn't replace the judgment. It shortens the distance from a question to a plan I can stand behind.

That's how this one went. AI helped me map the existing flow and draft the change. I wrote the transition plan and walked the team through it. The plan was sound, and we shipped it. It still missed all three seams.

The reason isn't "AI is a junior and I'm the senior who catches its mistakes." AI is good at the code that's there. None of these bugs were in the code that's there. They were in things written down nowhere: which customer configs exist, how people actually move through the screen, where the tenant context silently rides along. You can't read those in the new code, because they aren't in it.

There's a scoping piece too, and it has nothing to do with how smart the model is. When you hand a model a task, you hand it a boundary, and it works inside that boundary. The blast radius of the change doesn't respect the boundary. A change scoped to "the new feature's logic" came out in the rendering path, the refresh trigger, and the tenant plumbing, three places outside the frame I'd drawn. That's not the model failing the task. That's the scope not matching the system. And I draw the scope too narrow for the same reason it does: the parts that break are the parts nobody marked as connected. The blind spot was shared, and it came from two directions at once.

The same three mistakes, at backend scale

None of this is specific to web frameworks. It's what modernization does at any scale, and it gets more expensive the deeper down it goes. The same three mistakes show up when you break a monolith into services, and they show up in an order most write-ups skip.

Nobody starts at the database. You start at the easy edges. Logging goes first, because it's nearly standalone and pulling it out costs almost nothing. The database comes last, because the database is where these migrations stall. Logic is cheap to move; data isn't. So the monolith database keeps running as the source of truth for everything that hasn't moved, each new service comes up with its own store, and a warehouse on the side stays in step through change-data-capture over an event hub or Kafka.

The textbook says you finish by decommissioning the monolith and its database. In practice that step usually never comes. The old database stays underneath as load-bearing infrastructure nobody is funded to pull out. It's too entangled to be worth removing, so you leave it, the same way you leave a bug that's out of scope for the feature that's already shipping. The pattern is supposed to replace the monolith. Most of the time it just learns to live with it.

Every one of those moves is one of the three bugs, grown up. Routing capabilities around the monolith instead of through it is the same sibling-or-child question: does this run beside the old path, or inside it. The event-fed warehouse is the expired heuristic: it stays correct exactly as long as every real change emits an event, and the first write that slips through without one leaves the read store quietly wrong, with no error to announce it. And the cross-service context is the lost-boundary problem, which is the worst of the three. In the monolith, tenant and trace and transaction are all just present, because everything shares one scope. Add a service hop and none of it travels unless you carry it on purpose.

// Monolith: context is ambient, just there
public void Handle(Request request)
{
    Process(request);          // tenant, trace, transaction all implicitly available
}

// Distributed: the boundary strips it unless you pack it in explicitly
public async Task Publish(OrderEvent message)
{
    // forgot to attach TenantId / TraceId to the message...
    await _bus.Send(message);
}

public async Task Consume(OrderEvent message)
{
    var tenant = message.TenantId;   // null — whose data is this? nobody knows.
    // ...processing happens against the wrong (or no) tenant scope
}
Enter fullscreen mode Exit fullscreen mode

My screen bug was a tenant ID that didn't survive a sub-request inside a single process. The same bug across services is a tenant ID that doesn't make it onto a published message, and it surfaces in a consumer far away that's now processing data without knowing whose it is. Same mistake, much bigger radius. It's the whole reason distributed tracing and explicit context propagation exist as a discipline: ambient context doesn't cross boundaries on its own.

There's a smaller one in the same family worth naming, because it bites early. When you finally do move logic out of a stored procedure and into application code, people expect the database load to drop. It usually climbs. The procedure did one round trip with a join:

-- one round trip, one result set
SELECT o.*, c.name, t.weight
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN tasks t     ON t.order_id = o.id
WHERE o.id = @id;
Enter fullscreen mode Exit fullscreen mode

The "modernized" version moves the join into code and quietly turns one query into many:

var order = _repo.GetOrder(id);                        // query 1
var customer = _repo.GetCustomer(order.CustomerId);    // query 2
foreach (var task in order.Tasks)                      // lazy-loaded...
{
    task.Weight = _repo.GetWeight(task.Id);            // query 3, 4, 5, ... N
}
Enter fullscreen mode Exit fullscreen mode

The logic moved and the data access multiplied. Moving logic out of the database doesn't move the data out of it. If you don't redesign the access along the way, you've built something slower and called it modern.

Left: one thick arrow from app to database labeled

So the three things that broke one small screen are the three things that shape, and routinely break, a large migration: what runs beside the old path versus inside it, data that has to stay consistent across a gap that used to be a single transaction, and context that doesn't survive a boundary.

What I do differently now

I don't use AI less. Discovery is faster and the plans are better for it. What changed is where my attention goes.

The model handles the code that's there. The part that's mine is the code that isn't: the assumptions written down nowhere, the configs that only exist in production, the context that rides along until a boundary drops it. So I spend less time on the new code and more on a single question, what assumption am I about to break, and where is it written down. It almost never is written down anywhere, which is exactly why it's still the part that needs a person.

The feature was never where the risk lived. The risk lived in assumptions someone made years earlier and never expected to change. That's where I start looking now, not because the new code doesn't matter, but because the bugs were almost never in it.

Top comments (0)