Code Comprehension in the Age of AI

The Tower of Babel
Bruegel’s structure built faster than anyone could hold it in their head.
Until recently, understanding code was a byproduct of writing it. To implement a feature, however small, you first had to understand what you were trying to do, then work out how to do it, write the code, make mistakes, and fix them. Every line was written by you, every variable name chosen by you, every alternative carefully considered. You spent so much time thinking through how a feature should work and how it should fit into the existing code that, by the time you were finished, you understood it very well. When a bug was reported, I would instinctively know where to fix it. That’s how well I knew the codebases I used to work on.
With AI coding assistants, that is no longer true. You can describe a feature, get a working implementation in minutes, run the tests, and merge it. But you didn’t write it, and you didn’t have to think it through, so the understanding never formed. If someone asks you a month later why the retry has jitter, or what happens when the cache and the database disagree, the honest answer is “I’d have to look.” We have always had to deal with code we don’t understand, but it used to be other people’s code. Now it’s our “own”.
Code without a theory
Peter Naur wrote about this in 1985, in a paper called Programming as Theory Building. His argument was that a program isn’t really the source code. It’s the theory that lives in the heads of the people who built it: how it works, why it’s built the way it is, and how it should be changed. The source code is only a partial expression of it, and you can’t recover the rest from the code alone. What has changed since then is that we can now produce the code without ever building the theory.
Sometimes the cost shows up right away. A colleague asks in code review why it’s done this way and not another, or a product manager wants to know exactly why something behaves the way it does, and you don’t have an answer. Other times it shows up much later, during an incident when nobody on call can come up with a hypothesis because nobody has a mental model of the system anymore, or after a change that looked safe went out and broke an invariant that only ever existed in the code. And every change made without understanding leaves the code a little harder to understand for whoever comes next, so over time you can end up with a codebase that is perfectly readable and that nobody actually understands.
Robert Laszczak makes this point in Writing code isn’t the bottleneck anymore, reading is: it doesn’t matter how much code you can generate, only how much of it you can take accountability for. I don’t think there is a way to fully close the gap between the two, but there are two habits that have helped me keep it under control.
Don’t accept what you can’t explain
Before a change goes in, I should be able to explain it to a colleague without looking at the diff: what it does, why it’s done this way, and what would break if it were removed. If I can’t do that, I don’t understand the change yet, no matter what the tests say. Tests tell you that the code works. They don’t tell you that you know why it works.
In practice, this means working in smaller steps than the tool would let you. The model is perfectly happy to generate five hundred lines in one go, but nobody can properly review five hundred lines in one go. You skim them, and skimming is not understanding. So ask for less, read all of it, make sure you could explain it, and only then ask for the next piece. This feels slower, and it is slower than pretending to review. It’s a lot faster than debugging code you never actually read.
Keep a map, and point the tool at it
Nobody can keep a whole codebase in their head anymore, so the understanding worth keeping is one level up. What are the main pieces? How does data flow between them? Which invariants have to hold? Why were the non-obvious decisions made the way they were? Write that down, briefly, and keep it next to the code. Something like an ARCHITECTURE.md works well, as long as it stays concrete: rules like “the outbox table is the only thing that writes to the event bus”, and the decisions that would look wrong to a newcomer, along with the reason they aren’t. A page like that outlives any amount of implementation detail, and it’s short enough that people actually keep it in their heads.
Then use the assistant in the other direction. The same tool that produces code faster than you can read it can also explain code faster than you could work through it yourself. Ask it to walk you through a module you didn’t write. Ask it to compare the code against your map and tell you where the two disagree; every disagreement is either a bug or a sign that the map has gone stale, and both are worth knowing about. When a change lands that you weren’t part of, have it summarized before you touch it. The tool doesn’t have to be the reason you stop understanding your system. It can just as well be the reason you keep up with it.
The goal isn’t to slow the machine down. It’s to make sure that somebody, somewhere, still understands what the code does.