Skip to content
Blog Programming

How I Keep My Code Readable for the Next Person

By Romi Fadli, S.Kom. 24 min read Software Design
On this page
A panel of code lines, some starting with highlighted names, connected by three curves to a person, a clock, and a chat window whose older history is faded above a dashed line.

Most of the code I write will be read by someone who was not there when I wrote it. Sometimes that is a colleague picking up a service I built. More often it is me, six months later, with no memory of why a function looks the way it does. And more and more often it is an AI agent that opens the repository with no memory at all, reads a few lines around a search result, and is expected to change them correctly.

I work across several languages — Rust, Python, Node.js, PHP — and the rules I use to keep code readable for that next reader are the same in all of them. This article describes those rules and the reasons I hold them, using one small service as the case study. The service happens to be written in Rust. The rules are not about Rust.

Introduction

Code is read far more often than it is written. Robert C. Martin puts the ratio of time spent reading code to time spent writing it at well over ten to one, and the reason is simple once you notice it: almost every change starts with reading. Before I add a line, I read the function it goes into, the functions that call it, and the types it touches. If that reading is slow, the writing is slow too, no matter how fast I type. The cost of a codebase is not in the first version. It is in every change that follows, and every one of those changes begins with someone trying to understand what is already there.

That is the whole argument for clean code in one paragraph, and it is the argument Martin makes in Clean Code: A Handbook of Agile Software Craftsmanship (2008). The book is a long list of practical rules — how to name things, how large a function should be, how to handle errors, when to write a comment — and each rule serves the same purpose: to make the next reading cheaper. I did not invent any of the basic rules below. What I have done is apply them for years across different languages and projects, notice which ones matter most, and add one argument the book could not have made when it was written.

A. Who the next person is

When I say "the next person", I mean three different readers, and they need slightly different things.

The first is a colleague. They know the language and the framework, but not my intentions. They can ask me questions, but only if I am available, and every question they have to ask is a sign that the code failed to answer it on its own.

The second is me, later. This reader is the most underestimated. I know my own style, but I no longer remember the details: which edge case forced that odd branch, why a value is validated in one place and not another. Code that seemed obvious while I was writing it becomes a puzzle within weeks, because what made it obvious was the context in my head, and that context does not survive.

The third reader is an AI coding agent — a language model that works inside a repository by searching files, reading excerpts, running commands, and editing code in a loop. Agents are now part of how most programmers I know work, and they read code in a way that differs from humans in one crucial respect: they have no memory beyond what is currently in front of them.

That limit has a name. A model's context window is the amount of text it can hold at once — the conversation, the files it has opened, the output of its commands. When a long session fills the window, many tools apply compaction: the older part of the conversation is replaced by a short summary so the work can continue. The summary keeps the gist and drops the details, and the details dropped are often exactly the definitions the agent read an hour earlier. A new session starts with an empty window. Switching to a different agent starts from nothing as well. In every one of these cases, the agent meets the code again as a stranger.

An agent also rarely reads a whole file. It searches for a name, opens the twenty or forty lines around each match, and reasons from that excerpt. Whatever meaning is not visible in those lines — in a name, a type, a comment next to the decision — simply does not exist for it at that moment.

B. The case study: a storage service called OSW

The examples in this article come from OSW, a small service I maintain that sits in front of object storage. Object storage is a way of keeping files as whole objects in named containers called buckets, reached over HTTP instead of through a filesystem. The best-known interface for it is Amazon S3, and MinIO is a self-hosted server that speaks the same protocol. Applications talk to OSW rather than to the storage directly. It hands out presigned URLs — temporary links that let a browser upload or download one specific file without ever holding the storage credentials — and it manages a simple lifecycle: fresh uploads land in a temporary area, are committed to their permanent place once the application confirms them, and are moved to a deleted area rather than destroyed outright.

OSW is a good case study because it exists in two versions. The earlier one was a working service of roughly two thousand lines with all of its logic in one flat folder of services. It did its job, and it was hard to change. I rebuilt it according to the rules in this article while keeping every response identical for the applications already using it. That second constraint matters here: it means every difference between the two versions is a difference in readability, not in behaviour. Both versions are Rust, but the problems in the earlier one were not Rust problems. I have seen every one of them in Python, JavaScript, and PHP, and the fixes translate directly — the Results section shows how.

The earlier version was not badly written in the usual sense. Nothing in it was reckless. It was written by someone who understood the whole service at the moment of writing, and that is precisely what made it hard to read for anyone who did not.

C. Five obstacles in the way

So the goal is easy to state: write code that the next reader — colleague, future me, or an agent with no memory — can understand from the code itself, quickly enough that changing it stays cheap, in whatever language the project happens to use.

Easy to state, awkward to practise. Most unreadable code is not the result of carelessness but of five specific habits that each feel reasonable at the time, and each has an obvious alternative that does not actually work.

The first obstacle is names that hide what they mean. Every function in the earlier OSW was called execute, and the type names ended in Entity whether or not they were entities. The obvious remedy is documentation: a README or a wiki page that explains what everything is. I rejected it, because documentation lives somewhere else, and a reader looking at a call site does not see it. A name is the only explanation that travels with the code into every place it is used.

The second is functions that do more than one thing. One function generated presigned URLs from eight separate arguments. Another deleted a file in one of two very different ways depending on a boolean. The usual defence is that splitting them produces more functions to keep track of. But a function whose behaviour depends on a flag is two functions sharing a name, and a call with eight positional arguments cannot be read without looking up the signature.

The third is errors that travel as text. When a file was missing, the earlier version returned an error whose message began with NOT_FOUND:, and the HTTP layer checked for that prefix to decide on a 404. The tempting view is that it works, so it is fine. It works only as long as two pieces of code in different files agree on a string, and nothing — no compiler, no test, no reader looking at one side — enforces that agreement.

The fourth is that the reasons behind the code are not in the code. Many lines in a storage service exist for a reason that is invisible from the line itself: a validation placed early on purpose, a URL built in an unusual way because the signature depends on it. The alternative people reach for is either no comments at all ("the code should speak for itself") or comments that restate what the code does. The first loses the reason; the second goes stale the first time the code changes. Alongside that sits the lack of tests, which leaves the reader unable to tell which behaviour is intended and which is accidental.

The fifth is that the next reader may remember nothing. Every rule above was written for humans, who at least build up familiarity with a codebase over time. An AI agent does not. Its understanding is rebuilt from scratch whenever its context is compacted, cleared, or handed to a different tool. The alternative — writing long explanations into the conversation or into instruction files for the agent — fails for the same reason documentation fails: it sits apart from the line being read, and it is exactly the kind of text that compaction discards first.

The order matters. Names come first because everything else is built from them: a function is judged by its name, an error type is a name, and a test is mostly a name followed by an assertion. The fifth obstacle comes last because it is not a separate technique. It is a new reason why the first four matter more today than they did when the book was written.

Method

A. Names that say what they mean

Here is the answer to the first obstacle: I choose names so that a reader who sees only the call site understands what happens, without opening the definition. Ward Cunningham's description of clean code, which Martin quotes, is the test I use: you are reading clean code when each routine turns out to be pretty much what you expected from its name.

The clearest example in OSW is the file lifecycle. In the earlier version, the lifecycle existed only as string literals: "temporary/" in one function, "deleted/" in three others, each concatenated or stripped by hand. A reader had to find every occurrence to understand that these strings together formed a state machine. Now the concept has a name of its own:

rust
// Where in its lifecycle an object currently sits.
pub enum Lifecycle {
    Permanent,
    Temporary,
    Deleted,
}

impl Lifecycle {
    // Build a full path by prepending this lifecycle's prefix to `tail`.
    pub fn apply(self, tail: &str) -> String { /* … */ }

    // Strip this lifecycle's prefix, or `None` if the path lacks it.
    pub fn strip(self, path: &str) -> Option<&str> { /* … */ }
}

A line such as Lifecycle::Deleted.apply(&path) reads as a sentence. It tells the reader that files have states, that "deleted" is one of them, and that the state is encoded in the path — none of which the literal "deleted/" ever said.

I apply the same rule to type names. I dropped the Entity suffix because it described where a type had been placed, not what it was: FileMetadataEntity became FileMetadata, and the six functions called execute became use cases named for what they do — CommitTemporaryFile, RestoreFile, GeneratePresignedUrl. I will accept a long name over a short one every time. A long name costs a few characters at each use; a vague name costs a trip to the definition at each use, and the reader pays that trip, not the writer.

B. Functions that do one thing

A function should do one thing, at one level of detail, and its arguments should be few enough to read without looking them up. The function that generated presigned URLs used to take eight separate arguments. A call to it was a row of values whose meaning depended entirely on their position. I replaced them with one named input:

rust
pub struct GeneratePresignedUrlCommand {
    pub content_type: String,
    pub permission: String,
    pub count: i64,
    pub path: String,
    pub bucket: String,
    pub is_temporary: bool,
}

Now every value is labelled wherever the command is built, and the dependencies that never change between calls — the storage client, the clock, the ID generator — moved into the use case's constructor instead of being passed on every call. The same shape exists in every language I use: a dataclass in Python, a plain object with a type in TypeScript, a small readonly class in PHP.

Flag arguments are the other half of this rule. Martin's objection is that a boolean parameter announces that the function does two things, one when the flag is true and another when it is false. OSW still has one, and I prefer to say so plainly rather than present a cleaner picture than the code supports: DeleteFile branches on is_soft_delete. What changed is that the flag now sits inside a named command rather than as a bare true in an argument list, and each branch returns a differently named outcome — DeleteOutcome::soft_deleted or DeleteOutcome::hard_deleted — so a reader always knows which path ran. Splitting it into two use cases is the natural next step. The difference is that the remaining flag is now visible and named, which is what makes that step easy to take.

C. Errors that carry their meaning in their type

The third obstacle had the most fragile solution in the earlier version. Its shape was roughly this:

rust
// earlier version, reduced to its shape
return Err(anyhow!("NOT_FOUND:file does not exist"));

// … in a different file, in the HTTP layer
if msg.starts_with("NOT_FOUND:") {
    let text = msg.trim_start_matches("NOT_FOUND:");
    return not_found(text);
}

Nothing connects those two halves except a string that both happen to spell the same way. Change one, and missing files quietly turn into server errors. I replaced the text with a type that names every way a use case can fail:

rust
pub enum UseCaseError {
    // Input failed validation (one or more field errors).
    Validation { messages: Vec<String> },
    // The requested object does not exist.
    NotFound { path: String, bucket: String },
    // A deployment problem, e.g. a bucket missing from the pool.
    Configuration(String),
    // Anything else from the storage or network layer.
    Storage { message: String, source: Option<anyhow::Error> },
}

The translation into HTTP now happens in exactly one function at the edge of the service, and the compiler checks that every variant is handled:

rust
pub fn translate(err: UseCaseError, operation: &str) -> HttpResponse {
    match err {
        UseCaseError::Validation { messages } => bad_request(messages),
        UseCaseError::NotFound { path, bucket } => not_found(&path, &bucket),
        UseCaseError::Configuration(_) | UseCaseError::Storage { .. } => {
            server_error(operation)
        }
    }
}

The same rule appears in Martin's chapter on error handling as a preference for exceptions over return codes, and as the advice to give errors enough context to act on. In languages with exceptions, the equivalent is a small hierarchy of exception classes caught in one place. What matters is not the mechanism but that the meaning of a failure lives in something the language can check, rather than in text that only humans compare.

D. Comments for why, tests for what

Martin writes that the proper use of comments is to compensate for our failure to express ourselves in code. I agree about comments that describe what the code does: if the code needs that, the names or the structure are wrong, and the comment will drift out of date the first time someone edits the line beneath it. Where I part ways with a strict reading of the rule is the comment that records why. No name can carry a reason. Here is one from OSW:

rust
pub fn new(pool: BucketPool, public_origin: String) -> Result<Self> {
    // Validating here rather than per-presign means a malformed
    // `s3_public_origin` fails startup instead of the first request.
    Url::parse(&public_origin)
        .map_err(|e| anyhow!("invalid s3_public_origin '{}': {}", public_origin, e))?;
    // …
}

Without the comment, a reader tidying up could reasonably move that check to where the value is used, and the service would start successfully with a broken setting and fail on its first real request. The comment makes the decision visible at the point where it could be undone.

Tests answer the other half: which behaviour is intended. I name tests as sentences about behaviour, so that the list of test names reads like a specification, and I run use cases against in-memory versions of their dependencies so the tests need no real storage server:

rust
#[tokio::test]
async fn soft_delete_copies_then_deletes_and_returns_legacy_message() {
    let repo = InMemoryFileRepo::new()
        .with_object("private", "uploads/a.jpg")
        .into_arc();
    let uc = DeleteFile::new(repo.clone(), Arc::new(TestConfig));
    // …
}

That second part depends on an architectural choice: the use case talks to storage through an interface — a port, in the vocabulary of Clean Architecture — rather than calling the S3 client directly. In the earlier version, the services called storage functions directly, so any test of them needed a running storage server. With tests in place, cleaning code stops being risky, and that is what allows it to happen continuously instead of in rare, nervous rewrites.

E. Code that survives a reader with no memory

The last obstacle is where my own experience goes beyond the book. I work with AI agents every day, and what I have seen repeatedly is that the quality of an agent's change depends heavily on how much meaning the surrounding code carries by itself.

Consider what an agent sees when it is asked to fix a 404 that has turned into a 500. In the earlier OSW, a search would find msg.starts_with("NOT_FOUND:"). That line is only half the story; the other half is in whichever file produces the string. If the agent read that file earlier in the session, the knowledge may already have been lost to compaction. In a fresh session it never had it. The plausible fix is to adjust the text on the side it can see — which silently breaks every other place that depends on the old spelling. In the current OSW, the same search finds UseCaseError::NotFound { path, bucket }. The meaning is in the type, the type is checked by the compiler, and any change the agent makes to one side that the other side does not match fails to build instead of failing in production.

So I apply one extra test to the rules above: could a reader understand this line if it were the only thing they had read today? A name like Lifecycle::Deleted passes. A literal "deleted/" does not. A command struct with labelled fields passes; eight positional arguments do not. A comment that states a reason next to the decision passes; an explanation written earlier in a conversation that has since been summarised away does not. Clean code turns out to be exactly the property that makes context loss harmless: when every line explains itself, losing the rest of the context costs little.

There is a practical bonus as well. Agents find code by searching, and distinctive names make searches precise. Searching for Lifecycle::Deleted returns every place the deleted state is used and nothing else; searching for "deleted/" returns log messages, comments, and unrelated paths along with the ones that matter.

Results and Discussion

A. What changed in OSW

Rebuilding OSW this way changed its structure substantially while leaving its external behaviour untouched. Every response the applications depend on stayed the same, down to the wording of error messages, and the published API description stayed identical. The differences are all on the reading side:

AspectEarlier versionCurrent version
Use casessix functions named execute in one flat foldernamed use cases grouped by resource: file, presigned URL, temporary
Response helperstwelve near-duplicate functionsone presenter
Bucket validationthe same check written out five timesone validation module
Errorstext prefixed with NOT_FOUND:a four-variant error type, translated to HTTP in one place
Storage accesscalled directly from servicesbehind ports, with in-memory versions for tests
Testsnone of the use cases testable without a storage server98 tests, none of which needs a running server
What changed between the two versions of OSW, with its external behaviour held constant.

The most useful measure is not in the table. I added two features afterwards — a server-side copy between buckets, and a batch endpoint that signs up to a hundred download links in one request — and each landed in the same predictable places: an input type, a use case with its own tests, a result type in the domain, a method on the storage adapter where one was needed, a handler, and a line of wiring. The structure told me where each piece belonged before I wrote it.

B. The same rules in other languages

None of this is specific to Rust. The rules are about readers, and every language gives them a form:

RuleRustPythonTypeScript (Node.js)PHP
Name a concept instead of repeating literalsenumEnumstring union or enumenum (8.1+)
Group many arguments into one inputstruct@dataclasstyped object parameterreadonly class
Carry failure meaning in a typeerror enumexception classesError subclassesexception classes
Depend on a port, not a clienttraitProtocol or ABCinterfaceinterface
The same four rules expressed in the four languages I use most.

In a Python service built on FastAPI, for example, the error rule looks like this — a named exception, and one translation at the edge:

python
class NotFound(Exception):
    def __init__(self, path: str, bucket: str):
        super().__init__(f"file '{path}' does not exist in bucket '{bucket}'")
        self.path, self.bucket = path, bucket

# one translation to HTTP, at the edge of the service
@app.exception_handler(NotFound)
async def not_found(request: Request, err: NotFound):
    return JSONResponse(status_code=404, content={"errors": [str(err)]})

What differs is how much help the language gives in enforcing them. Rust's compiler refuses a match that forgets an error variant; in Python, the same guarantee has to come from a test. The rule does not change with the tooling. Only the safety net does.

C. What the book could not anticipate

Clean Code was published in 2008, long before AI coding agents existed. Its reader is a human programmer, and its arguments are about human attention and human memory. Those arguments still hold. But today almost every programmer I know works alongside an agent, which means a large share of the reading that happens in a codebase is done by a reader with a peculiar weakness: it forgets.

My view is that this makes the book more relevant, not less. An agent's lack of memory is an extreme version of the problem Martin was already solving. A human colleague forgets gradually; an agent forgets completely, at every compaction, every new session, every switch to a different tool. Rules designed to make code understandable without the author's context turn out to be exactly the rules that make code understandable without any context at all. A good name is the one piece of explanation that is still present when everything else has been summarised away.

The reverse is also true, and it is the part I find most important in daily work. An unreadable codebase is not just slower for an agent; it is more dangerous. A human who does not understand a line usually stops and asks. An agent tends to produce a plausible change and move on. The less the code explains itself, the more of those plausible changes are wrong in ways nobody notices until later.

D. Where I stop

Clean code has costs, and I try to keep them in proportion. Renaming for its own sake produces large diffs that make history harder to follow, so I rename when I am already changing the code or when a name is actively misleading. I do not split a function merely because it passes an arbitrary line count; I split it when it does two things. And, as the flag in DeleteFile shows, I prefer a visible, named imperfection to a hidden one. The purpose is not a codebase that follows every rule. It is a codebase whose next reader can work without asking me anything.

Conclusion

I write code for the reader who comes next: a colleague, myself months later, or an AI agent that meets the code as a stranger every time its context resets. The rules I use are Robert C. Martin's, and they work the same way in Rust, Python, TypeScript, and PHP: names that state their meaning, functions that do one thing with few arguments, errors whose meaning lives in a type, comments that record why, and tests that record what. OSW shows what they change in practice — the same behaviour, reached through code that can be read one excerpt at a time.

The one idea I would add to the book is the reason it matters more now. Every rule that helps a human who lacks the author's context also helps a reader who lacks all context, and that reader is now present in most codebases every day. Readable code is the only context that survives.

Terms

Clean code
Code written so that the next reader can understand and change it cheaply. The term comes from Robert C. Martin's book of the same name.
AI coding agent
A language model that works inside a code repository by searching, reading excerpts, running commands, and editing files in a loop.
Context window
The amount of text a language model can hold and reason over at one time.
Compaction
Replacing the older part of a long agent session with a short summary so the session can continue; details, including definitions read earlier, are lost.
Object storage
Storage that keeps files as whole objects in named buckets, reached over HTTP. Amazon S3 defines the common interface; MinIO is a self-hosted server compatible with it.
Presigned URL
A temporary link that allows one specific upload or download without giving the client the storage credentials.
Flag argument
A boolean parameter that switches a function between two different behaviours.
Port
An interface through which application logic reaches an external system, so the system can be replaced — for example by an in-memory version in tests.

Technologies covered

  • Python
  • Rust

About the author

Photo of Romi Fadli

Romi Fadli, S.Kom.

Solution Architect · Backend Engineer · Embedded Systems

In my day job I work as a solution architect. I design the system, lead the team that builds it — quality assurance, backend developers, a UI developer, DevOps and DevSecOps — and write the most critical modules myself, such as a certificate authority, a long-term PDF signer, and the core API of a personnel system.

I work across the whole stack that a running system actually needs. On the hardware side that means PCB design, microcontrollers, and embedded firmware — including RISC-V boards running FreeRTOS and Embassy, speaking MQTT over mutually authenticated TLS on a microcontroller with 64 KB of SRAM.

Most of my day-to-day work is software: Rust backends and API modules built on Clean Architecture, an OAuth2/OpenID Connect authorization server written from scratch, PAdES document signing, and certificate authorities — root CAs, timestamping authorities, and OCSP responders. I also build the frontends that sit on top of them.

Running all of that means infrastructure: Docker Compose stacks, networking, Mikrotik and RADIUS, observability with OpenTelemetry, and a steady habit of writing command-line tools and utilities for Linux when the existing ones do not fit.

Share

Enjoyed this?

Get an email when a new article is published.

Referenced by

A stack of Markdown documents and an orange arrow leading to a browser page with a side menu, a heading, paragraphs, a dark code panel, and a table of contents with its active entry highlighted

A single script that turns a project's Markdown documentation into a static site that opens from a folder with no server or internet: a menu, a table of contents that follows the reader, tables that never truncate, code with a copy button, and links that keep working.

7 min read Read more →
A browser window connected directly to a storage bucket by a thick green band, while an orange box with a key above them hands the browser a dashed link.

A small Rust service between applications and S3-compatible storage: it hands out presigned links so browsers talk to storage directly, gives each file a lifecycle, copies between buckets on the server, and keeps storage credentials out of every application.

12 min read Read more →

Related Articles

Four flat layers stacked on the left — routes, controllers, services, repositories — joined by short lines, with an orange line labelled later: refactor leading to the concentric circles of Clean Architecture on the right

One folder per kind of code at a single level, a short path from route to repository, one file per operation — the shape I give a Rust service that must work fast or fit a local AI agent's context, before it grows into Clean Architecture.

7 min read Read more →