Self-Documenting Code Has a Blind Spot: the Why
Table of contents
You open a file you wrote six months ago. The functions are named well. The variables read like plain English. There are no clever one-liners, no cryptic abbreviations, no dead code. By every standard you were taught, this is good, self-documenting code, and you are proud of it. Then you hit one function that does something slightly strange. It retries a call three times, then falls back to a cached value that is allowed to be up to an hour stale. The names are perfect. You can see exactly what it does. And you cannot for the life of you remember why. Why three retries and not five? Why is an hour of staleness acceptable here when it would be a bug anywhere else? You wrote this. The code told you everything except the one thing you needed.
That gap is the whole argument. "Self-documenting code" is one of the most reliable ways to start a fight among engineers, and both sides are usually arguing about the wrong thing. One camp says good names and clean structure make comments unnecessary. The other says comments are essential and anyone who skips them is lazy. They are both talking about how to document what the code does. Almost nobody is talking about the thing code genuinely cannot document, which is why the code is the way it is. Get that distinction straight and the entire debate resolves into something useful. Here is what self-documenting code actually is, what it can and cannot carry, and where the reasoning has to live instead. (Scribelet is built to keep that reasoning from going missing, but the discipline matters whatever tool you use.)
What is self-documenting code?
Self-documenting code is code written so that its purpose and behavior are clear from the code itself, primarily through descriptive names, small focused functions, clear control flow, and expressive types, with minimal reliance on comments to explain what is happening. The idea, associated with the Clean Code tradition and decades of practice before it, is that a comment explaining what a line does is a symptom: if the line needed the comment, the line was not clear enough. Rename the variable, extract the function, and the comment becomes redundant.
This is real and it is worth doing. A function called calculateTax(order) tells you more at a glance than process(x) with a comment that says "calculates tax." A name that honestly predicts its behavior is one application of the principle of least surprise: make the code behave the way whoever reads the name already expects. Code that reads top to bottom without surprises is genuinely easier to maintain than code padded with narration. The self-documenting instinct is correct about a large and important category of comments: the ones that restate the mechanics. Those comments rot, they drift out of sync with the code they describe, and they are noise. If your only defense of comments is "explain what each line does," the self-documenting camp is right and you should write better names instead.
The mistake is treating that win as the whole game. Naming and structure document the what. They are silent, by construction, about the why, and the why is usually the part you actually come back for.
The two things code has to explain, and only one is in the code
Every piece of nontrivial code answers two separate questions, and they are not the same question.
The first is what does this do. What is the input, what is the output, what is the sequence of steps. This is mechanical, it is fully present in the source, and good naming and structure express it well. If you cannot tell what a function does from reading it, that is a code-quality problem, and self-documenting code is the right fix. And when clear code still hides a bug, explaining it line by line to a rubber duck usually surfaces it, because asserting each step out loud catches the assumption your eyes kept skimming past.
The second is why is it like this. Why this approach and not the obvious alternative. What constraint forced the ugly branch. What you tried first that did not work. What will break if someone "simplifies" it later. A parser deliberately made liberal in what it accepts is the sharpest case: the code plainly shows that it tolerates a malformed input, and says nothing about which partner it was forgiving or when the leniency can finally be removed. This information is not in the code, and it cannot be, because the rejected alternatives are not in the code. The three-retry loop does not contain the incident that taught you the downstream service flaps for exactly two retries under load. The one-hour staleness does not contain the product decision that this screen tolerates stale data and the billing screen does not. The code is the answer; the why is the question it was answering, and the question is gone. It usually goes unwritten for a specific reason: to the person who made the call, the reasoning felt too obvious to record, which is the curse of knowledge doing its quiet damage. The stakes are highest for a line that looks wasteful but is quietly guarding speed: delete the cache or the deliberately un-abstracted hot path because nothing records why it exists, and you reopen the performance gap a lean system fought to close.
| Question | Lives in the code? | Best expressed by |
|---|---|---|
| What does this do? | Yes | Names, structure, types, small functions |
| What are the steps, in order? | Yes | Control flow, function decomposition |
| Why this approach over the obvious one? | No | A recorded decision (comment, commit, ADR, spec) |
| What did we try that failed? | No | A decision log or postmortem |
| What breaks if you "clean this up"? | No | A comment at the site, or a linked decision record |
| What constraint or deadline shaped it? | No | Context outside the code entirely |
Read that table and the debate dissolves. The self-documenting camp is right about the top rows and wrong to generalize to the bottom ones. The pro-comment camp is right that something has to carry the bottom rows and wrong to reach for line-by-line narration to do it. Clean names are necessary. They are not sufficient.
A function that documents itself perfectly and still tells you nothing
Here is the retry example, written as well as I know how. Every name is honest, the structure is flat, there is nothing to rename.
async function getExchangeRate(currencyPair) {
for (let attempt = 0; attempt < 3; attempt++) {
const rate = await rateProvider.fetch(currencyPair);
if (rate.isFresh) return rate;
}
return rateCache.get(currencyPair, { maxStalenessMinutes: 60 });
}You can read every line. You know exactly what it does. Now answer the questions that matter in a review or an incident at 2 a.m.: Why three attempts? Why is a stale rate acceptable at all in a function about money? Why sixty minutes and not five? The code is mute, and no amount of renaming will make it speak, because the answers are facts about the world outside the function. The honest version needs one thing the self-documenting version cannot provide:
async function getExchangeRate(currencyPair) {
// Provider flaps under load and recovers within 2 retries (INC-4412).
// A third attempt is cheap insurance; a fourth never helped in the data.
for (let attempt = 0; attempt < 3; attempt++) {
const rate = await rateProvider.fetch(currencyPair);
if (rate.isFresh) return rate;
}
// Display-only surface: a <1h stale rate is a product-approved tradeoff
// (see pricing spec). Do NOT copy this fallback into billing.
return rateCache.get(currencyPair, { maxStalenessMinutes: 60 });
}Those comments do not explain what the code does. The code already did that. They explain why it does it, and they point at the incident and the spec where the full reasoning lives. That is the category of comment self-documenting code should never delete, because there is no name you can give a variable that encodes "we learned this from INC-4412."
When a comment earns its place
Not every comment is worth keeping, and the self-documenting instinct is a good filter. The test is simple: a comment that restates the code is noise and should be deleted in favor of a better name; a comment that supplies something the code cannot contain is signal and should be kept and maintained. Use this to decide what to do with any given comment.
| The comment says... | Do this instead |
|---|---|
| What a line mechanically does ("increment the counter") | Delete it. Rename the variable or extract the function so the code says it. |
| What a block does, because the block is a mess | Extract the block into a well-named function. The name replaces the comment. |
| Why this approach was chosen over an obvious alternative | Keep it. This is the why; code cannot hold it. |
| What non-obvious constraint or bug shaped the code | Keep it, and link the incident or ticket. |
| What will break if someone changes this | Keep it. It is a warning to the next person, including future you. |
| A whole subsystem's design and tradeoffs | Move it out of the comment and into a durable record: a design document or an architecture decision record. |
The pattern is that the what gets pushed into the code through naming and extraction, and the why gets pulled out of the code into a comment or, when it is bigger than a comment, into a document. Self-documenting code and good documentation are not opponents. They divide the labor.
Where the "why" actually belongs
Once you accept that the why has to live somewhere, the next question is where. A comment at the code site is the right home for a local, small reason. A larger reason, one that spans files or explains a whole design, outgrows the comment and needs a real record. These channels are not interchangeable, and picking the wrong one is why reasoning gets lost.
| Channel | Good for | Fails at |
|---|---|---|
| Names and structure | What the code does | Anything about why |
| Inline comment | A local why, right where it applies | Design-level reasoning; gets buried, one reader at a time |
| Docstring / API doc | Contract of a function or module for callers | The reasoning behind the contract |
| Commit message | Why this specific change happened | Discoverability later; nobody greps git log during a review |
| Architecture decision record (ADR) | A durable, discoverable design decision and its rejected alternatives | Tiny local quirks (overkill) |
| Technical spec or design doc | The reasoning for a whole feature before it is built | Line-level detail after the fact |
Most teams already have some of these. The failure is rarely that the channels do not exist; it is that the why never gets written into any of them, so it stays in one person's head. That is how a well-named codebase still ends up with a bus factor of one, and how the next engineer inherits code they can read perfectly and cannot safely change, which is Chesterton's fence in its purest form: a fence with no sign, and no way to tell if it is load-bearing without testing it the hard way. There is a second, subtler cost to leaving the why unwritten: without a recorded contract for what a function actually promises, its callers infer the contract from watching it run, and eventually depend on whatever it happens to do rather than what you meant it to do. That is Hyrum's law, and a docstring that states the intended contract explicitly is one of the few things that keeps an implicit interface from silently becoming the real one. If keeping that reasoning attached to your work sounds like a chore worth automating, that is the problem Scribelet exists for.
The part nobody maintains: your reasons decay too
Here is the failure mode underneath the whole debate. Suppose you do the right thing. You write self-documenting code for the what, and you record the why in a comment or an ADR. You are still not safe, because the why does not stay true on its own. The retry comment that said "provider flaps within 2 retries, INC-4412" was accurate the day you wrote it. Then the provider fixed their infrastructure, or you migrated to a new one, and now the comment describes a world that no longer exists. It reads with total authority, it points at a real incident, and it is wrong. A confidently wrong comment is worse than no comment, because the next engineer trusts it and designs around a constraint that was lifted a year ago.
This is knowledge decay, and it hits the why far harder than the what, for a simple reason: the code's behavior is checked constantly by tests and by production, so a false claim about what the code does gets caught. A claim about why is never executed. Nothing tests a comment. So the reasoning quietly goes out of date while the code keeps running, and the gap between what the comment says and what is actually true widens with every month nobody rereads it. Self-documenting code cannot save you here either, because the thing that rotted was never in the code to begin with.
The problem is getting sharper, not softer, because of how code is written now. Agentic coding tools produce clean, well-named, genuinely self-documenting code faster than any human, and they strip the why completely. The agent that chose three retries had a reason in its context window, and that reason evaporates the moment the session ends. You are left with immaculate code and zero record of the tradeoffs behind it, which is the self-documenting blind spot at machine scale. The more of your codebase is written by something that cannot remember why, the more the why has to be captured deliberately and kept current, or it is gone.
This is what Scribelet is built for. Its background agents re-check the notes, decisions, and docs you keep against reality on a schedule and show you a diff when a claim no longer holds, so the "why" behind a piece of code does not silently drift into fiction. Your keys, your model, your data: the agents run on the provider you choose, and you see exactly what changed and why. The reasoning behind your code is internal documentation like any other, and it is worth keeping honest.
Getting started
Stop arguing about whether to write comments. Split the question instead. For the what, write self-documenting code: good names, small functions, clear flow, and delete every comment that just narrates the mechanics. For the why, do the opposite of what the self-documenting purists preach: write it down deliberately, right where it applies for a local reason and in a durable record for a design-level one, because the code cannot hold it and your memory will not either.
Then maintain the why the way you maintain the code, because it decays the same way and nothing tests it for you. Try Scribelet free and let it keep the reasoning behind your code honest as the code, and the world it runs in, keep changing. The best-named function in your codebase still cannot tell the next person why. That part is on you to write down, and to keep true.
Share this article