Three months ago I approved a PR in eleven minutes. Claude Code wrote it, the diff was clean, tests were green, and it did exactly what the ticket asked. Last week that same file caused a production incident. Not because the code was wrong. Because nobody, including me, actually understood it anymore.
Everyone measures AI coding tools by how fast they ship the first version. Nobody measures what it costs to touch that code again in month four. I've been running Claude Code across production TypeScript projects for the better part of this year, and the maintenance bill is the part I got wrong.
The velocity number is real. It's also the wrong number.
I'm not walking back what I've written before. The 200K-line JS-to-TS migration really did take six weeks with zero new production bugs. The refactor that used to take three hours really does take twenty minutes now. Those numbers hold up.
What I didn't track: how long it takes to safely change that code a second time, by a different person, three months later. That number went up. I just wasn't measuring it, so I didn't notice until it showed up as an incident.
Here's the pattern I see now across four teams I've worked with this year. Week one, velocity triples. Everyone's thrilled. Week eight, the codebase has three times more code than it would have organically, most of it never touched again, and the parts that do get touched take longer to change than equivalent hand-written code did.
Coverage theater is the trap
The incident above involved a payment retry function. It had 94% test coverage. Here's roughly what those tests looked like:
it('retries the payment', async () => {
const result = await retryPayment(mockPayment)
expect(result).toBeDefined()
})
it('handles failure', async () => {
const result = await retryPayment(failingPayment)
expect(retryPayment).toHaveBeenCalled()
})
Every line executes. Nothing is actually verified. toBeDefined() passes on literally any return value including an error object. The second test doesn't even check the outcome, it checks that the function was called, which was never in question.
Claude Code writes tests like this constantly when you just ask for "tests for this function." It's not being lazy, it's satisfying the instruction. Coverage went up, confidence went up, and neither one meant anything. The bug that shipped was a retry counter that reset on a specific timeout error instead of incrementing. All four tests passed against it, forever, because none of them asserted the counter value.
The fix isn't more tests. It's specifying behavior instead of asking for tests.
it('increments the retry counter on timeout, does not reset it', async () => {
const payment = createPayment({ retryCount: 2 })
const result = await retryPayment(payment, { failureType: 'timeout' })
expect(result.retryCount).toBe(3)
})
That one line of extra specificity in the prompt is the difference between a test that catches the bug and a test that just looks like it does. I now write the assertion I want before I ask for the implementation. Takes two extra minutes. Would have saved four hours of production debugging.
Review gets faster and worse at the same time
This is the part that surprised me most. AI-generated code is stylistically consistent. Consistent formatting, consistent naming, consistent structure. That consistency makes it read as trustworthy, and trustworthy-looking code gets reviewed faster and with less scrutiny.
I caught myself doing this. A diff that "looks like Claude wrote it" gets a faster skim than a messy human diff, even though the messy human diff is statistically more likely to have an obvious bug and less likely to have a subtle one. The bugs that survive AI code review aren't syntax errors, nobody misses those. They're logic errors dressed in clean code. A retry counter reset. An off-by-one in a date range. A null check that handles the wrong branch of the union type.
What changed my review process: I stopped reviewing AI-generated diffs the same way I review human diffs. For human code I'm checking "does this make sense." For AI-generated code I'm checking "does this handle the case the ticket didn't mention." Different question, different pass. Takes longer per PR, not less time, whatever the marketing says about AI review speeding things up.
The debt is architectural, not stylistic
Bad naming and inconsistent formatting are debt you can see in a diff. The debt I actually worry about now doesn't show up in a diff at all: it's the same problem solved four different ways across a codebase because each PR was one isolated session with no memory of the other three times someone solved it.
We had this exact thing happen with date range validation. Four different implementations across four features, all written by Claude Code in separate sessions over six weeks, each one internally consistent and individually correct. None of them called the same function. When a bug got fixed in one, it stayed broken in the other three, because nobody realized there were four to begin with, including the person reviewing each individual PR.
This is the direct cost of a tool with no persistent memory of your codebase decisions. A human engineer who wrote date validation last month remembers writing it and reaches for it again, or at least feels a flicker of "didn't I do this already." Claude Code starts every session cold unless you give it something to read.
The fix that actually worked for us, documented in the codebase itself:
## Existing Utilities — Check Before Writing
- Date range validation: `src/shared/validateDateRange.ts`
- Retry logic with backoff: `src/shared/withRetry.ts`
- Currency formatting: `src/shared/formatCurrency.ts`
Before writing a new utility, search this list and the `shared/` directory.
Three lines in CLAUDE.md cut duplicate utility functions in that codebase from something we were finding weekly to something we found twice in the following two months. Cheap fix. We just hadn't been maintaining CLAUDE.md as living documentation, we'd written it once at project start and left it there.
What actually changed in how I work
I'm not less bullish on AI-assisted development than I was six months ago. I'm bullish on a narrower slice of it.
I stopped asking for "tests" and started specifying assertions. The prompt now includes the exact expected value, not just "add test coverage."
I review AI diffs for the unstated case, not the stated one. The ticket describes the happy path by definition. The review has to hunt for what it doesn't describe.
CLAUDE.md is now a living document, not a one-time setup step. Every time I catch a duplicate implementation, it gets a line in CLAUDE.md pointing at the canonical one. That file grows every week or it's not doing its job.
I track a second number alongside velocity. Not just "how fast did this ship" but "how long did the next change to this file take, and did the person making it understand why it was built that way." That second number is the one that predicts incidents.
The maintenance cost of AI-generated code isn't a reason to slow down. It's a reason to stop measuring the wrong thing. Shipping fast was never the hard part of software. Understanding what you shipped six months later was always the hard part, we just used to pay that cost slowly enough not to notice it.
If you've run AI-generated code past the three-month mark on a real team, I'd genuinely like to know if the duplicate-implementation problem shows up for you too, or if that one's specific to how we work. Follow me on Twitter or LinkedIn for more on this.
Originally published on my Hashnode blog. Follow me for more AI + Architecture content.
Top comments (0)