This is how a codebase decays

March 31, 2026

Last Thursday, a bug came through a feedback channel where a customer saw an unexpectedly large "next payout" amount in their dashboard on payday, followed by roughly half that amount landing in their bank account later that day. Excitement -> disappointment.

I knew the likely culprit the moment I saw the report. We have a function that determines the next payout date — every other Thursday — and the dashboard shows the total of all payments collected in that window. On a payout Thursday, the date correctly advances to the next period after 12am UTC. But if the payout hasn't actually been created yet, for a brief window on Thursday afternoons, the system will sum up this period's payments and next period's payments and call it one number.

As with most bugs of this scale, I fired off a request to Devin in Slack:

@Devin bug with getNextPayoutDate(): on the day of a payout, the system can show the amount being paid today plus the amount scheduled for the next payout, which makes the displayed expected payout jump unexpectedly. the payout date anchor should not advance to the next date until today's payout is created.

Devin landed a PR a few minutes later. It fixed the bug, added tests, and our AI code reviewer [1] didn't flag any issues. It was a busy Thursday; this was maybe the 11th most important thing I worked on that day. My mouse hovered over the squash and merge button.

However.

The way Devin solved it was by turning our pure function into an async function that queries the database for the next payout. This solution makes sense in isolation: the API endpoint that tells us the date and amount of the next payout is this function's only consumer. So, straightforwardly, and per the ticket, we will "not advance the anchor to the next date until today's payout is created" by making a database call to get said payout.

The tests mocked database records. It solved the bug. It passed review. It was fine. But where we previously had a function that took in a date and returned a date, we now had an async function with the same input & output and a database query in the middle.


This is how a codebase decays. One reasonable PR at a time, each one written by a really smart engineer with the memory of a goldfish and no peripheral vision. A pure function becomes async. The tests get a little bit slower. Reusable logic gets tightly coupled to an API route. Every change is fine, but the codebase is not.

I've never seen an LLM consider, on its own and without prompting, whether a change makes a codebase more pleasant to work with. They're optimizing for the ticket, not the way the system feels. And the things that make a codebase pleasant to work with — where boundaries are drawn, which systems we trust, which abstractions feel good and help us move fast — are exactly the things agents are blind to. Left unchecked, agents are merchants of complexity, cheerfully solving every problem by adding more moving parts. They will never feel the pain of working with a bad abstraction.

Some say this doesn't matter anymore. I say it does. Even on a busy Thursday. Especially on a busy Thursday.

So I fired off a second message in Slack:

@Devin Look at PR 3989. Let's step back and solve this holistically. Create a pure function in the shared package with unit tests called calculateNextPayoutDate(). It should take a starting date (default: today), a timezone (default: America/Los_Angeles), and optionally the datetime of the most recent payout. Let the function handle the overlap case. Add unit tests. Open a new PR off main when finished.

A few minutes later I had a clean PR: a pure function with a nice API and fast tests that takes dates and returns a date, handles the day-of logic with a second input date, and is ready to plug into other areas of the codebase that want to know about payout dates.

Total time spent across both PRs: five minutes.


Fixing bugs like these used to take part of a morning, and good engineering taste entered through the act of writing the code. Now the code writes itself in a couple of minutes on a magic machine in the cloud while we work on more important things. But where does taste come from? (hint: not agents!) And so my job is no longer writing functions with nice APIs; it's directing machines to do this — and thousands of other tasks — in a way that's good for the long-term health of the codebase.

This is what I think about when I hear folks talk about code review being the "last bottleneck". Maybe it is. But agents compound these small architectural mistakes faster than any human ever could, and they'll never feel the pain of working with the mess they leave behind. I see code review as the last line of defense against spiraling complexity. So I'm fine with keeping that, even if it means some things still move at human speed.


[1] Also Devin. It's very good - it catches subtle downstream bugs that a human reviewer would likely miss without encyclopedic knowledge of the codebase. But it has never said, "hm, this is a bad abstraction."