Skip to main content

Comments in Code: AI and the Last Resort

As I continue working on projects of my own, as well as those in my day job, I am learning more about the best ways to use AI to create software. Mostly Claude and sometimes Codex.

One thing I cannot help noticing is the sheer number of comments AI adds to the code.

In January 2025, I wrote a blog post called Comments in Code: Just Don’t Do It, in which I quoted Uncle Bob:

It is well known that I prefer code that has few comments. I code by the principle that good code does not require many comments. Indeed, I have often suggested that every comment represents a failure to make the code self explanatory. I have advised programmers to consider comments as a last resort.

I went on to say:

  • Comments should always be “why”, never “what”, and only when     absolutely necessary.
  • If you feel the need to write a “what” comment, put the code in     a well-named, meaning descriptive, function instead.

I stand by this view. Comments should be few and far between and, when they are necessary, should explain “why”. I have had long and sometimes heated discussions with senior people about this. I have stood firm for more than 20 years. However, I am now allowing AI to litter the code it writes for me with comments.

This made me wonder whether I was ignoring my own advice. So, with Claude, I looked at the codebase of one of my recent projects to see what sort of comments it had actually added.

The codebase has an unusually consistent, essay-like comment style. It is clearly deliberate rather than incidental. However, almost none of the comments describe what the code does. The identifiers already tell you what the code does. Instead, they explain why the code is the way it is.

A few patterns stood out.

Why this design and not the obvious alternative

From Logs.tsx:

// Deliberately not reported. The row fetch is asking the 
// same BFFabout the same window, and has already bannered 
// whatever wentwrong; a second banner for one outage would
// name a chart nobody was looking at. LOGS-14.
const counted = histogram.data;

The obvious implementation would check histogram.isError and display an error banner.

The comment explains why that error path was deliberately not built. The row fetch has already reported the same underlying failure, so displaying another banner would tell the user about a chart they were not looking at and make a single outage appear to be two problems.

It also references LOGS-14, where the decision presumably came from.

Without the comment, a future software engineer, or AI, might see the missing error handling as an oversight and helpfully put it back.

A non-obvious gotcha

From client.ts:

for (const [key, value] of Object.entries(params)) {
  // Before the emptiness test rather than inside it: 
  // `String(null)` is "null", which is a param whose 
  // value is the word rather than a param with no value,
  // and it is wrong in a way only the wire would show.
  if (value === null) search.set(key, "");
    else if (value !== undefined && value !== "")
  search.set(key, String(value));
}

The code is reasonably clear about what it does. The comment explains why null is handled in its own branch before the generic String(value) path.

Without it, null would silently become the literal string "null" on the wire. That is the sort of bug which looks perfectly reasonable in the code and only becomes obvious when inspecting the network traffic.

Again, the comment is not narrating the code. It is recording a trap. However, you could argue that a software engineer, and an AI, should already know how String(null) works.

A cross-repository contract

From the top of metrics.ts:

// The contract for the read calls is the OpenAPI block 
// above each handler in the sibling `omnilog-query` 
// repo (`src/handlers/metrics-*.mts`, and `services.mts`
// for the shared services route); the BFF proxies them
// verbatim, query string included. Nothing here is 
// cursor-paginated, so nothing here goes through `toPage`
/ — each answer is one envelope holding the whole of it.
//
// `fetchMetricDescription`/`updateMetricDescription` are 
// the one pair thatisn't a proxy: a metric's description 
// is domain data the BFF owns itself, the same precedent
// dashboards set (pjgrenyer/omnilog-frontend#8) ...

This is a file-level comment rather than one attached to a particular function.

It tells the reader that the real API contract lives in a different repository entirely. It also explains that the BFF proxies those calls verbatim and that they are not cursor-paginated, which is why they do not use the otherwise familiar toPage path.

Finally, it identifies the only two functions that break the pattern and links that exception back to the pull request that established the precedent.

None of this could be expressed adequately through a better function name.

Why a test is shaped this way

From stalled-session-check.test.tsx:

// The failure the shell had no answer for. `App` shows 
// the splash and nothing under it while GET /api/me is 
// in flight — deliberately, because the sign-in gate 
// would tell a signed-in user they are signed out — and 
// that is only safe if the request is certain to settle.
// A BFF that stalls rather than errors settles nothing,
// and the user is left watching the mark with no explanation
// and no retry, forever.
//
// The bound is `SESSION_TIMEOUT_MS`, so these run the clock
// rather than the wall: waiting the real interval out would
// make this the slowest file in the suite and prove exactly 
// the same thing.

The test itself includes:

it("when the session check has not yet run out of time, then the shell has not given up on it", async () => {
  // The other half of the bargain. Falling through 
  // early would reintroduce the flash of the wrong screen
  // that waiting behind the splash exists to avoid.
  vi.useFakeTimers();
  stubBff({ "GET /api/me": stalls });

The file-level comment explains why the test exists at all.

The application deliberately shows only the splash screen while GET /api/me is in flight. Showing the sign-in screen could briefly tell a signed-in user that they had been signed out; it is safe only if the request is guaranteed to finish.

A BFF which fails produces an error. A BFF which stalls produces nothing, leaving the user staring at the splash screen forever.

The rest explains why the test advances fake time and guards both boundaries: the application must eventually give up, but not so early that it flashes the wrong screen.

Have I changed my mind?

Not really.

These comments all follow the rule I set out last year. They explain why, not what. They record design decisions, hidden traps, external contracts and failure modes. Removing them would remove information which the code alone cannot express.

What has changed is the cost of writing and maintaining comments. Traditionally, software engineers aren’t great at keeping them up to date. Code changes, the corresponding comment does not, and the comment becomes inaccurate and useless, if not dangerously misleading. In my experience, AI is better at keeping comments in sync, provided it has the relevant context. It never complains about updating the documentation.

Software engineers often understand why they have written something but do not take the time to capture that reasoning. When AI has been part of the discussion that led to the code, it can turn that reasoning into a comment. Perhaps too readily. Left unchecked, it will happily write an essay beside every decision.

That still needs judgement. A long comment which merely translates the code into English is still noise. An incorrect or out-of-date comment is still worse than no comment. AI-generated comments need reviewing just as much as AI-generated code does.

But a comment which stops the next software engineer from “fixing” a deliberate decision, exposes a bug that would otherwise only appear in production, or records a contract which lives in another repository is useful.

So I still believe comments should be a last resort.

It turns out AI can be rather good at finding the occasions when that last resort is justified.

 

Comments

Popular posts from this blog

It's great to be back at the ACCU Conference 2025!

Last week I was back at the ACCU Conference in Bristol, for the first time since I gave an opening keynote in 2019 . In March 2020, COVID hit the UK and I got out of the habit of attending and speaking at conferences. Plus the ACCU Conference, and the organisation in general, took a big step back towards C++ and that hasn’t been relevant to me for nearly two decades. It’s taken until now, 2025 for me to want to talk again, and I was really pleased to be accepted for the ACCU Conference again. It was just a 20 minute session and I only attended for the day I was speaking. There was enough varied content for it to be interesting to me and I’m hoping to be back for the full conference next year. I really enjoyed attending and it was great to catch up with people I hadn’t seen for years. I felt re-engaged and particularly liked being asked if I’d been to nor(DEV):con . Learning to stop writing code (and why you won't miss it) Daisy Hollman I should have read the summary of the openin...

Write Your Own Load Balancer: A worked Example

I was out walking with a techie friend of mine I’d not seen for a while and he asked me if I’d written anything recently. I hadn’t, other than an article on data sharing a few months before and I realised I was missing it. Well, not the writing itself, but the end result. In the last few weeks, another friend of mine, John Cricket , has been setting weekly code challenges via linkedin and his new website, https://codingchallenges.fyi/ . They were all quite interesting, but one in particular on writing load balancers appealed, so I thought I’d kill two birds with one stone and write up a worked example. You’ll find my worked example below. The challenge itself is italics and voice is that of John Crickets. The Coding Challenge https://codingchallenges.fyi/challenges/challenge-load-balancer/ Write Your Own Load Balancer This challenge is to build your own application layer load balancer. A load balancer sits in front of a group of servers and routes client requests across all of the serv...

Catalina-Ant for Tomcat 7

I recently upgraded from Tomcat 6 to Tomcat 7 and all of my Ant deployment scripts stopped working. I eventually worked out why and made the necessary changes, but there doesn’t seem to be a complete description of how to use Catalina-Ant for Tomcat 7 on the web so I thought I'd write one. To start with, make sure Tomcat manager is configured for use by Catalina-Ant. Make sure that manager-script is included in the roles for one of the users in TOMCAT_HOME/conf/tomcat-users.xml . For example: <tomcat-users> <user name="admin" password="s3cr£t" roles="manager-gui, manager-script "/> </tomcat-users> Catalina-Ant for Tomcat 6 was encapsulated within a single JAR file. Catalina-Ant for Tomcat 7 requires four JAR files. One from TOMCAT_HOME/bin : tomcat-juli.jar and three from TOMCAT_HOME/lib: catalina-ant.jar tomcat-coyote.jar tomcat-util.jar There are at least three ways of making the JARs available to Ant: Copy the JARs into th...