Myth: “AI-Generated Code Doesn’t Need To Be Easy To Understand”

You may see people online claiming that “AI”-generated code doesn’t need to be easy for humans to understand, because humans won’t need to.

Bah humbug!

It’s quite clear that the factors that make code easier for us to wrap our heads around also make LLM performance on it better (less unreliable).

LLMs struggle with inconsistent, unclear naming because that hampers pattern matching.

LLMs struggle with complexity because they were trained on so little of it (though they’re very capable of generating it, ironically).

LLMs struggle when concerns aren’t cleanly separated, because that means more source code has to be brought into the context – and they don’t like that!

Think of the context in each interaction with an LLM as being analogous to cognitive load. We strive to write code in a way that reduces cognitive load for the reader.

Far from being less important when using LLMs for code generation, modification or summarising/documenting, it’s twice as important! LLMs aren’t as smart as we are.

But putting all of that aside, anyone who’s tried using “AI” coding assistants for any significant length of time and on anything substantial will know that you will be spotting and fixing problems yourself. These tools are nowhere near being reliable enough that you can just leave them to it. They’re not compilers.

You can file “AI-generated code doesn’t need to be easy to understand” under “Advice about running marathons from someone who ran the 400 m once”.

The AI-Ready Software Developer #10 – Comprehension Debt

In my previous post, I talked about the need to recognise when an “AI” coding assistant is circling the event horizon of a “doom loop” and take the wheel.

Taking the wheel, of course, requires that you can still drive and you know where the car’s supposed to be going.

In the next post I’ll talk about why it’s so important to maintain your edge as a programmer when you’re using these tools. But in this post, I want to explore one specific aspect of that: our understanding of the code the “AI” is generating.

Legacy code is something that has many software developers running screaming for the hills. A large part of the fear of legacy code is that it can be hard to comprehend, because somebody else – probably somebody who isn’t around anymore – wrote it.

When we’re asked to make a change to code we didn’t have a hand in writing, to do that safely – without breaking the software – we first need to wrap our heads around that code. And that takes time.

Studies vary in the details, but there can be no doubting – from eight decades of the business of software – that developers spend a lot more time reading code than we do writing it.

(The wisdom holds therefore that we should optimise our approach for the ease of reading, not writing, code. Give it another eight decades, and maybe that message will finally sink in.)

The extra time it takes to understand code so that we can change it without breaking it is what I call comprehension debt. The bigger the gap to understanding, the bigger the debt that has to be paid, and the more expensive the change.

Attaching a code-generating firehose to our development process is an accelerant for the creation of comprehension debt. Pre-LLMs, legacy code was a big problem for our industry. Now it’s well on the way to being a major threat to society, with an increasing number of teams – often under pressure from management, who drank the “AI” Kool-Aid – pushing code nobody understands into production.

Maybe it works today, but what happens when it needs to change tomorrow? Because odds are, it will. Code that gets used gets changed.

It’s vitally important to keep on top of the code that the machine is spitting out at a vast rate of knots. It’s vitally important that we really understand it. We need to read it, think about it, and inwardly digest its meaning.

This puts a hard limit on the speed of code generation, which isn’t about how many tokens per second the model can predict, but how many tokens per second we can understand.

When we’re drinking from the firehose, the limit isn’t the firehose. The limit is us.

This is the main reason I don’t let “AI” coding assistants directly affect my source code without running suggestions by me first. Only when I’ve fully grokked – pun intended – the changes and agree with them (which isn’t often) will I let them be applied without any interventions from me.

Working in small steps, solving one problem at a time, really helps here. The less code there is to comprehend, the more focus I can give to every decision the model suggests. I keep “AI” coding assistants on a very short leash. You will not find me – unless for experimentation – using these tools in any kind of “autonomous” or “agentic” mode.

And, of course, the same factors that make code easier to comprehend apply regardless of who wrote the code. Simplicity, clear naming (“says what it does on the tin”), and effective separation of concerns – so we can understand one aspect of the system without having to understand many others – all have their place here.

The usual poor substitutes for clear code – comments and documentation – are what LLMs tend to fall back on, so I look for opportunities to incorporate those messages into the code itself if I feel it’s needed. (And quite often, the comments, docstrings etc that models like Claude Opus and GPT-5 will add to code turn out to be redundant anyway.)

When explaining what code does, I try to make it clear in the code itself – to have it tell its own story. When I feel that I need to explain why it does it the way it does, then I might use inline documentation of some kind, like a comment.

Some “AI” coding assistant users will have the model generate Markdown files with explanations of what was done and why. These are about as useful as you’d expect, if you’ve ever been told to write, say, an architecture document. And, if you actually check the contents thoroughly, they usually don’t pass the “Brown M&Ms” test.

As one person put it: “Documentation is useful until you need it.” Often misleading. Often out of date. Often just ticking a box.

And, just as with legacy code, the big one is fast automated tests. The ability to quickly check that a change hasn’t broken anything is such a big factor in the cost of changing code that, in his book Working Effectively With Legacy Code, Michael Feathers defines “legacy code” as code that lacks those tests.

Well-written automated tests can also serve as living, executable documentation that shows us not just what we expect the code to do, but how to use or reuse it. I’ll take tests over comments and dosctrings any day of the week.

Anyhoo, back to the main point. When developers are generating code faster than they are understanding it, a mountain of comprehension debt can form very quickly.

It’s an age-old category mistake: optimising your dev process for adding, rather than changing, code.

You will pay for comprehension sooner or later, but remember that this debt accrues interest rapidly.

The AI-Ready Software Developer #6 – Continuous Refactoring

Finally, we get to the “R” word.

Our software works. We know, because we’ve been testing it continuously. And we’ve reviewed the code at every step, looking for areas that might need clarifying, looking for duplication that might need consolidating and abstracting, looking for modules that do or know too much, and/or are tightly coupled to other parts of the code, and looking for unnecessary complexity and redundancy that needs pruning.

Basically, looking for things that are going to make the code harder to change.

But when we find problems in the code, what are we gonna do about them?

Refactoring to the rescue! At least, if you know what the word means and know how to do it.

This is where some of the previous AI-ready dev practices conjoin. Refactoring, done safely, entails individual atomic rewrites – restructurings, if you like – of the code that bring it back to working.

Think of your source code as a database, and a refactoring – yes, it’s a noun, too – as a transaction against that database. It’s an all-or-nothing change that preserves the semantics of the data. The code means what it did before the transaction.

For example, if I want to rename a function, I can’t just change the name. I have to update all of the places in the code that the function’s called. That whole “Rename” refactoring has to complete for us to get back to code that works.

How do we know it works again? We run our fast automated tests. I knew they’d come in handy.

So, one atomic refactoring at a time – Rename a function, Extract a block of code into a new function, Move a function to a different module, and so on (refactorings are a bit like the moves of chess) – we restructure our code, testing after every refactoring to make sure we’re not wandering off the path of working, potentially shippable code into the deep dark forest of untested, un-shippable software. (You test before you ship, right?)

We solve one problem at a time. We test continuously. And, as we’ll explore in the next post, we bank every successful step using version control.

Refactoring by hand is a discipline that takes time to learn. IDEs that automate refactorings can help greatly in applying that discipline, which is why I remain a JetBrains user for any programming language their tools support, because they take refactoring more seriously than most.

Refactoring using LLMs, as has been the case with every other practice covered in this series, is really no different.

We get the best – least worst – results when we ask the model to perform one refactoring at a time.

We get better results when we clarify refactorings using examples (I’ve been growing a little library of Markdown files explaining the mechanics of each refactoring).

It’s essential that we test continuously while we’re refactoring.

And it’s essential to review the code again to see the effect of each refactoring.

And finally, if our code lacks reasonably good separation of concerns, even relatively simple restructurings are more likely to go bad – wider “blast radius”, bringing in too much context, throwing the model out of its training data distribution.

This is why it’s very important not to let the problems build up over hours or days (or weeks), thinking we can do a “big refactoring” later. (There’s no such thing as a “big refactoring”, BTW – that’s very much in the deep, dark forest.)

Never forget that LLMs are really good at generating code they’re really bad at modifying. Keep on top of every “code smell” the model spits out. Don’t let the cruft build up, because your LLM will very quickly be walking through a minefield of its own creation.

Personally, whenever possible I do refactoring using the automated tools built in to my IDEs. I’ll take predictable over powerful every time.

The AI-Ready Software Developer #5 – Continuous Inspection

So, we’re working in small steps, solving one problem at a time. We’re clarifying with examples to reduce the risk of models grabbing the wrong end of the prompt stick. We’re cleanly separating concerns to localise the “blast radius” of LLM-generated changes. And we’re continuously testing to get immediate feedback when the model breaks stuff.

Once we’re satisfied that the software’s still working, this might be a good opportunity to take a step back and examine the code that it generated or changed.

Most important, after making sure it works, is whether the code makes sense to us. Read it. (No, seriously, READ IT!) Can you understand what it’s doing? Can you understand why it’s doing it that way?

Code comprehensibility is a complex topic, since it’s a function not just of the code, but of what we can comprehend. (We’ll get to that in a later post.)

For now, suffice to say that every line of code that you don’t understand – or haven’t read – that makes it into the product adds something I’m calling “comprehension debt“. When you have to change code that you don’t understand, you’ll see what I mean.

So read the LLM’s code. Try to understand what it does and why. See if you can predict what it will do in specific test cases.

Another problem coding assistants are notorious for creating is duplication. They’re plagiarism machines – monkey see, monkey do. Bits of duplication here and there aren’t a problem. But the same code, or the same concept, repeated over and over definitely is. The Rule of Three can be helpful here.

And don’t forget that the real role of duplication in a design process is to signpost opportunities for reuse – to point us towards genuinely useful abstractions.

Of course, if you remove the duplication and it makes the code harder to understand, maybe put it back (or look for a better abstraction – one that “says what it does on the tin”).

Look, too, for problems with modular design. LLMs are really bad at modular design. Probably because their training data mostly consists of examples with low or no modularity, like Stack Overflow answers.

Yep. LLMs are really good at generating code that they’re really bad at modifying later.

Keep on top of the separation of concerns in your code, or they will lead you out into deep water and leave you to drown.

Look for modules that have multiple distinct reasons to change, and/or depend on too many other modules. Look for Feature Envy. And look for Primitive Obsession. Oh, boy, look for that!

And, lest we forget, look for code that isn’t being used and isn’t needed. LLMs aren’t noted for sticking to the brief. They will generate code you didn’t ask for.

And for the many low-level issues models may introduce – unused imports, visibilities higher than needed, data that could be immutable, and all that malarkey – I’m in the habit of running a linter with every code review. They can scan large amounts of code for dozens of issues very quickly, exhaustively, and deterministically.

DO NOT ask the model to mark its own homework. It misses tons, and it cheats.

“But that sounds like a lot, Jason.”

Not really. If you’re taking small steps, the amount of new or changed code will be just a few lines. If it hurts, do it more often!

Comprehension Debt: The Ticking Time Bomb of LLM-Generated Code

Psst. If your boss won’t invest in training you in Specification By Example (BDD, ATDD), I’m running out-of-hours workshops on May 12 and 16 specifically for self-funding learners. £99 + UK VAT.

An effect that’s being more and more widely reported is the increase in time it’s taking developers to modify or fix code that was generated by Large Language Models.

If you’ve worked on legacy systems that were written by other people, perhaps decades ago, you’ll recognise this phenomenon. Before we can safely change code, we first need to understand it – understand what it does, and also oftentimes why it does it the way it does. In that sense, this is nothing new.

What is new is the scale of the problem being created as lightning-speed code generators spew reams of unread code into millions of projects.

Teams that care about quality will take the time to review and understand (and more often than not, rework) LLM-generated code before it makes it into the repo. This slows things down, to the extent that any time saved using the LLM coding assistant is often canceled out by the downstream effort.

But some teams have opted for a different approach. They’re the ones checking in code nobody’s read, and that’s only been cursorily tested – if it’s been tested at all. And, evidently, there’s a lot of them.

When teams produce code faster than they can understand it, it creates what I’ve been calling “comprehension debt”. If the software gets used, then the odds are high that at some point that generated code will need to change. The “A.I.” boosters will say “We can just get the tool to do that”. And that might work maybe 70% of the time.

But those of us who’ve experimented a lot with using LLMs for code generation and modification know that there will be times when the tool just won’t be able to do it.

“Doom loops”, when we go round and round in circles trying to get an LLM, or a bunch of different LLMs, to fix a problem that it just doesn’t seem to be able to, are an everyday experience using this technology. Anyone claiming it doesn’t happen to them has either been extremely lucky, or is fibbing.

It’s pretty much guaranteed that there will be many times when we have to edit the code ourselves. The “comprehension debt” is the extra time it’s going to take us to understand it first.

And we’re sitting on a rapidly growing mountain of it.

Code Reviews as Exploratory Testing

Code reviews? Let me tell you about code reviews!

To me, a code review done by people is exploratory testing. We gather around a piece of code (e.g., a merge diff for a new feature). We go through the code and we ask ourselves “What do we think of this?”

Maybe we see a method or a function that has control flow nested 4 deep. Eurgh! Difficult to test and easy to break (such cyclomatic complexity, much wow). So we flag it up.

So far, so normal.

Once that code quality “bug” has been flagged up, I’m sure we both agree that it needs fixing. So we fix it. Job done? Now, here’s where you and I may part company.

It’s almost guaranteed that won’t be the last time that problem rears its head in our code. So, as well as fixing the complex conditional we found in our review, we also fix the process that allowed the problem to make it that far – and waste a bunch of time – in the first place.

When we find a logic error in our code by exploratory testing, we don’t just fix the bug. We write a regression test in case a future change breaks it again. (We do, right?)

And when we find a code quality bug, we shouldn’t just refactor that example. We should add a code quality check for it to our suite of code inspections – automated if at all possible – that will catch it as soon as it reappears somewhere else in the code.

Now, you can take this too far, as with all things. Automating regression tests is D.R.Y. applied to our testing process. If we need to perform the same test over and over, automating it makes a lot of sense.

But D.R.Y. has caveats, and one of those caveats is The Rule of Three. On average, we wait until we’ve seen something repeated 3 times before we refactor. This increases the odds that:

a. The refactoring will pay off later (the more examples we see, the more likely there’ll be more in the future)

b. We have more examples to guide us towards the better design.

Both apply as much to duplicated effort in our process as they do to duplication in our code.

So we might want to keep a log of the problems our code reviews find, and we see the same type of issue appear 3 times (or thereabouts), that might be our cue to look into building a check for it into our Continuous Inspection rule suite. Maybe our linter already has a rule we can use. Maybe we’ll need to write our own custom rule for it. (That’s a very undervalued skillset, BTW). Maybe we could train a small ANN to detect it. Maybe we’ll need to add it to the manual inspection checklist.

A good yardstick might be that the same type of code quality issue doesn’t appear in merges (or attempted merges) more than 3 times.

And there’s more. There are teams I’ve worked with who not only add a check to their Continuous Inspection suite, but also ask “Why does this problem happen in the first place?” How do 500-line functions become 500-line functions? How do deeply-nested IFs become deeply-nested IFs? How do classes end up with 12 distinct responsibilities and 25 dependencies?

The answer, BTW, is that – more often than not – the way functions get 500 lines long, IFs get deeply nested and classes end up doing so many different things is because the people writing that code didn’t see it as a problem.

And that’s usually where I come in 🙂