Clean code in the age of AI: writing for people and agents to read, change and extend

Code is now read, changed and extended by people and AI agents alike. Tidying a 26-line pricing function step by step: a test over 20,000 random orders first, then names, no magic numbers, no nested ifs, smaller functions, measured complexity, and how agents find, read and imitate code.

This series goes from small to large: one function, one class, a group of classes, then a whole system spread over many machines. The first post starts at the smallest scale, one function, with a practical question: what is "clean" code, and how do you tidy messy code without breaking it?

"Clean" here isn't about looks. A piece of code is written once but read dozens of times: when fixing a bug, adding a feature, reviewing, or when someone new reads it to understand the system. Clean code is code whose cost to understand and change is low.

And today, the "reader" isn't only a person. A growing share of code is read, changed and extended by coding agents: Claude Code, Copilot, Cursor, Codex... (the post The agent harness in the LLM series takes apart how they run). An agent is a very particular kind of reader: it remembers nothing between sessions, rereading from scratch every time; it finds code by searching for words; it reads through a limited context window; and most importantly, it imitates the code around it. Every rule in this post has two reasons: one for people, one for agents.

The route: a hard-to-read pricing function, a safety net before changing anything, names, magic numbers, untangling nested ifs, splitting functions by level of abstraction, measuring complexity before and after, putting comments in the right place, code as an agent sees it, then the points where "clean code" goes too far (including when the author is an agent).

Every number in this post was measured on a real machine, with Python 3.12.

A hard-to-read function

Here's a function that totals an order, the kind of code that turns up after a few years of "quick fixes to ship on time":

def calc(o, u):
    t = 0
    if o is not None:
        if len(o["items"]) > 0:
            for i in o["items"]:
                if i["qty"] > 0:
                    t += i["price"] * i["qty"]
            if u is not None and u["vip"]:
                if t > 1000000:
                    t = t * 0.9
                else:
                    t = t * 0.95
            else:
                if t > 1000000:
                    t = t * 0.97
            if o["country"] == "VN":
                t = t * 1.1
            elif o["country"] == "SG":
                t = t * 1.09
            else:
                t = t * 1.0
            return round(t)
        else:
            return 0
    else:
        return 0

Try answering quickly: how much does a VIP customer pay for a 2 million dong order shipped to Singapore? You have to trace each if branch, remember what t means on each line (the total before discount, after discount, or after tax?), and guess what 0.97 and 1.09 are. This function works. The problem is that every read takes minutes, and every change is a gamble, whether the one changing it is you or an agent.

Step 0: a safety net before changing anything

The most important rule of tidying code: don't change behaviour. Refactoring means changing structure while keeping the results. To be sure of that, you need a test that runs before you touch a single line.

The old function has no tests, and no documentation saying what it "should" do. The most practical approach is a characterization test: treat the old function as the answer key, generate lots of random inputs, and require the new function to give the same result:

import random
from messy import calc
from clean import order_total

random.seed(1)
for _ in range(20_000):
    items = [{"price": random.choice([0, 1000, 50000, 999999, 2000000]),
              "qty": random.randint(-1, 3)} for _ in range(random.randint(0, 4))]
    order = random.choice([None, {"items": items, "country": random.choice(["VN", "SG", "US"])}])
    user = random.choice([None, {"vip": True}, {"vip": False}])
    assert calc(order, user) == order_total(order, user), (order, user)

The inputs are chosen on purpose: prices around the 1 million threshold, negative and zero quantities, empty orders, None, three countries. After each step below, rerun this test. The final version gives the same result on all 20,000 random orders.

This test doesn't prove the two functions are identical, but it turns refactoring from "hopefully nothing broke" into "checked 20,000 cases, thresholds included". With agents it matters even more: an agent changes code many times faster than a person, and its "done, refactored" is only worth trusting when there's a test command it can run itself and see go red. Writing this test first, then handing the refactoring to an agent, is a very effective way to work: you define what "correct" means, the agent does the legwork, and the test is the referee.

Step 1: names that say what things mean

o, u, t, i, calc: the author knows what they are, the reader doesn't. A good name answers "what is this, what is it for" without reading the rest:

Before After Why
calc order_total Calculates what?
o, u order, user No guessing
t amount One variable, one meaning at any moment
i item i usually means an index; here it's a line item
u is not None and u["vip"] is_vip Give a condition a name

Longer isn't better. item in a two-line loop is enough; no need for current_order_line_item. The wider the scope, the clearer the name needs to be: a variable that lives for two lines can be short, a function called from ten places can't.

Names also have a role few people thought about before: they're what gets searched for. Agents (and people, in an unfamiliar repo) find code with grep. The section "code as an agent sees it" below measures how different the two versions are on that.

Step 2: no magic numbers

1000000, 0.97, 1.09 are magic numbers: values with business meaning but no name. When Singapore's tax changes, you have to find every 1.09 and guess which ones are the tax and which just happen to be 1.09 too. Name them, and put things of the same kind in one table:

BIG_ORDER = 1_000_000  # VND; above this, discounts are larger
VAT = {"VN": 0.10, "SG": 0.09}

The VAT table also exposes something: the old function's else: t = t * 1.0 branch is dead code, since multiplying by 1 does nothing. In the table, it becomes the default value VAT.get(country, 0.0), and adding a country means adding a row of data, not another elif branch.

Step 3: return early instead of nesting

The old function nests 4 levels deep: an if inside an if inside a for inside an if. The real work sits at the deepest level, and the two else: return 0 branches are at the very end, 20 lines from their conditions. Reaching the last return 0, you have to scroll back up to see which if it belongs to.

The fix is a guard clause: handle the special cases at the top of the function and return straight away:

if not order or not order["items"]:
    return 0

After this line, the rest of the function can assume the order exists and has items, and it no longer needs to be indented two levels.

Step 4: one job per function, one level of abstraction

The old function mixes three jobs at three levels of detail: adding up each line (detail), deciding the discount (a business rule), and applying tax (another business rule). Give each job its own named function, and the main function just tells the story:

BIG_ORDER = 1_000_000  # VND; above this, discounts are larger
VAT = {"VN": 0.10, "SG": 0.09}


def subtotal(items):
    return sum(item["price"] * item["qty"] for item in items if item["qty"] > 0)


def discount_rate(amount, is_vip):
    big = amount > BIG_ORDER
    if is_vip:
        return 0.10 if big else 0.05
    return 0.03 if big else 0.0


def order_total(order, user=None):
    if not order or not order["items"]:
        return 0
    amount = subtotal(order["items"])
    amount *= 1 - discount_rate(amount, is_vip=bool(user and user["vip"]))
    amount *= 1 + VAT.get(order["country"], 0.0)
    return round(amount)

The opening question now takes seconds: VIP, 2 million, above BIG_ORDER so 10% off, Singapore tax is 9%: 2,000,000 × 0.9 × 1.09 = 1,962,000. And each rule has a home of its own: changing the discount policy only touches discount_rate, changing tax only touches VAT. discount_rate can also be tested on its own, without building a whole order.

Measuring before and after

"Easier to read" sounds subjective, but some of it can be measured. A small script using Python's ast module counts, for each function: lines, cyclomatic complexity (1 plus the number of branch points such as if, loops and and/or, i.e. the number of independent paths through the function), and the deepest nesting:

Function Lines Complexity Deepest nesting
calc (old) 26 11 4
subtotal 2 3 0
discount_rate 5 4 1
order_total 7 4 1

The total line count barely changes. What changes is that no function is more complex than 4. A complexity of 11 means 11 independent paths through one function, and testing them all takes at least that many cases, each needing a whole order. Three small functions with 3–4 paths each are much easier to test one at a time. Tools such as ruff (rule C901) or radon measure exactly this, and many teams set a limit of about 10 per function. Putting that limit in the linter is also a way of "telling" agents: the code they write has to pass lint just like a person's.

Comments: explain why, not what

A comment like t += price * qty # add price times quantity doesn't help: the code already says that, and when the code changes, the comment is often forgotten and starts lying. Most "what" comments disappear once the names are good.

The valuable comments answer questions the code can't answer itself: why it's done this way, what isn't obvious, which trap was hit. A real example from this website's own code:

# Not in every system's MIME table (the slim Docker image lacks it); browsers expect font/woff2 for fonts
mimetypes.add_type("font/woff2", ".woff2")

Reading the line, you know what it does. Reading the comment, you know why it exists, and that you mustn't delete it just because "it works fine on my machine".

For agents, this kind of comment is a fence. An agent asked to "clean up unnecessary code" will see a MIME type registration that the development machine doesn't need, run the tests and see green (because the test suite doesn't run inside the slim Docker image), and have every reason to delete it. The "why" comment is the only thing in the file telling it what the tests can't catch. Knowledge in someone's head can't be read by an agent; knowledge in comments, names and tests can.

Code as an agent sees it

Three habits of agents make clean code matter more than before, and all three can be measured or seen on this post's own example.

1. Agents find code by searching for words. Ask an agent to "change the discount for VIP customers", and the first thing it usually does is search the repo for words like discount, vat, total. Counting the matching lines in the two versions:

Keyword Old function calc New version
discount 0 3
vat 0 2
total 0 3
vip 1 3

The old function doesn't contain the word "discount" once: the discount rule is in there, but it has no name. An agent (or someone new to the project) has to open and read each suspect file to work it out, costing time and context, and making it easy to change the wrong place. Good names make code findable.

2. Agents read through a limited context window. In this small example, the total token counts are almost equal: the old function is 199 tokens, the whole new version 193 (counted with the o200k_base tokenizer). The difference is in how much has to be read for one change: to change the discount in the new version, you only need discount_rate and the BIG_ORDER constant, about 72 tokens; in the old function, you have to read and understand all 199, because the rule is woven in with everything else. In a real repo of hundreds of thousands of lines, that difference multiplies: small functions, focused files and modules split by domain let an agent load just the part it needs, instead of stuffing half the repo into its context and skimming.

3. Agents imitate the code around them. This is the most important point. Agents write new code based on existing code: they look at how this project does similar things, then do the same. Give the same agent "add Thailand, 7% tax":

  • With the old function, the most natural move is to add a branch elif o["country"] == "TH": t = t * 1.07, another magic number, a longer if chain. Messy code breeds more messy code, and each addition becomes the template for the next.
  • With the new version, the most natural move is to add "TH": 0.07 to the VAT table. One line, no logic touched.

In other words, your current code is the prompt for every piece of code an agent will write next. A consistent, clean repo with clear patterns gets consistent, clean additions; a repo where every part does things differently makes the agent pick a style at random, usually the nearest one.

On top of that, today's agents read an instructions file at the root of the repo (CLAUDE.md, AGENTS.md, or an equivalent rules file) before starting work. That's the place for what can't be inferred from the code: the commands to run tests and lint (in this repo, uv run pytest and ./scripts/lint.sh), folder conventions, things not to touch. Keep it short and accurate: an outdated instructions file lies just like an outdated comment, except it's read in every session.

When "clean code" goes too far

Every rule above has a point where doing more hurts more than it helps:

  • Splitting too finely. Ten one-line functions, each calling the next, make the reader jump through ten places to understand one thing. Split when the piece has a meaningful name and a reason to exist on its own, not because of a "no function over 5 lines" rule.
  • Abstracting too early. Two pieces of code that look alike aren't necessarily the same concept. Merge them, then the two requirements drift apart, and the shared function grows if mode == ... flags. A practical rule: wait for the third repetition (the rule of three) before merging. Duplicated code is cheaper than the wrong abstraction.
  • Performance. Each function call in Python costs about 14 ns (measured later in this series). For a pricing function called a few times per request, that's nothing. In a hot loop running tens of millions of times it matters, and there, measure before deciding.

These traps apply in full to code written by agents, and some are even more common there. Writing code with an agent costs almost no effort, so it's easy to end up with too much code: a wrapper nobody needs, a try/except swallowing every error "just in case", a configuration option for something that never changes, comments narrating what each line does. Code written fast still has to be read, reviewed and maintained at the same cost as code a person wrote. So review agent code by exactly this post's standards: are the names clear, any magic numbers, any branches that aren't needed, does it follow a pattern already in the repo or invent a new one? The shortest diff that does the job right is usually the best one.

Clean code is a tool for lowering the cost of change, not a goal. The right question isn't "does this follow the rules" but "can the next person, or the next agent, understand and change it quickly and correctly". That next person is usually you, three months from now, asking an agent to read it for you.

Summary

Practice For people For agents
Test before refactoring Keep behaviour the same; 20,000 random orders give the same result One command for the agent to check itself, instead of trusting "done"
Meaningful names Understand a line without reading the whole function Findable with grep: "discount" 0 matches against 3
Named constants, data tables One rule, one place to change A new country becomes a row of data, not an elif
Guard clauses, one job per function Complexity from 11 down to at most 4 Read ~72 instead of 199 tokens for one change
"Why" comments Record what the code can't say itself A fence stopping agents deleting what tests can't catch
Consistent patterns, an instructions file Predictable ways of doing things Today's code is the prompt for the code agents write next
Don't overdo it Wait for the third repetition Review agent code by the same standards; the shortest diff that works

This post stopped at one function. When code grows into many classes and modules that depend on each other, the question becomes: how do you divide responsibilities, and which way should dependencies point, so you can replace one part without bringing down the rest? Next post: the five SOLID principles, applied to Python.