Chapter 11 of 18

Design

What makes code good to change: naming, coupling, complexity, refactoring, and how slop gets in.

Every test passes, the agent says it is finished, and it is right. Three months later a small request takes all afternoon, because one rule now lives in four files and nobody can say which of them matter. Nothing failed along the way. No check turned red on the day that went wrong, because no check was looking.

This chapter takes one position: good code is code that is cheap to change. That matters more when something else keeps changing it. An agent will make the next change whether the code is ready for it or not, and it reads the mess afresh at the start of each session. It can also make code quietly harder to change at a rate no person could manage.

Be careful with what follows, because most of it is craft. These ideas come from people who wrote a lot of software and noticed patterns, and very few of them have been tested properly. Martin Fowler says of his own central belief, that design pays for itself, that "there is no objective proof that this phenomenon actually occurs." Experts disagree in writing, and this chapter shows you where.

Where a number exists, this chapter hands it to you to operate, including a case where its own author forced a score down and made the code worse. Knowing what a metric can't see is as useful as knowing what it says.

Good code

Also called Changeability

Code that is cheap to change correctly. Two programs can pass the same tests today and differ hugely in what next week's request costs. Working is the minimum, and how easily it changes is what separates them.

A codebase with an agent in it gets changed far more often, and each session starts by rereading it from nothing. Every change pays for the shape the last one left behind, and no test reports that price going up.

Say it to your agent

“Before you add this, tell me how many files the next change like it would touch. If it is more than two, first restructure so the rule lives in one place, keeping the tests green, and then make the change.”

Naming

Choosing what to call things so a reader can guess what they do without opening them. unpaidInvoices tells you something. data tells you almost nothing.

An agent finds its way round a codebase largely by searching for words. If the thing it needs has a vague name, the search misses, and it may write a second copy of something that already exists. A name is also the cheapest context there is, since it arrives at every place the thing is used.

Say it to your agent

“In src/billing/reminders.ts, rename data to unpaidInvoices and process to sendPaymentReminder across every caller, using the language server's rename and not find and replace. Behaviour must not change. Run bun test afterwards.”

Now the first real fight. Two well-known authors argued this out in writing, over months, and neither of them won.

Small functions

Also called Method length, One Thing

The advice that functions should be short, so each does one thing and has a name that says what. How short is where people stop agreeing. One famous book says very short. Another says too short makes code harder to read.

Splitting is mechanical, so an agent told to shrink functions can keep going without limit, and the numbers improve whether the code got clearer or not. Someone has to decide when to stop, and for now that is you.

Say it to your agent

“Split this function only where the new piece hides something its caller no longer has to think about. Don't create helpers that share module-level variables. If I would have to read two functions together to follow either one, keep them as one.”

Cohesion

How much the things inside one module belong together. High cohesion means that when one part changes, the rest tend to change with it, and nothing unrelated gets dragged along.

An agent tends to add code wherever it is already looking, so a file can grow by convenience rather than by meaning. Low cohesion makes it read a lot to change a little, and that reading comes straight out of its context.

Say it to your agent

“src/utils.ts has become a drawer for everything. List what each export is for, group them by what changes together, move them to src/dates.ts and src/money.ts, and delete utils.ts when it is empty.”

Coupling

How much one module has to know about another. If changing A forces you to change B, they are coupled. Modules have to talk to each other, so you can't get rid of it. The questions are how much, and whether you can see it.

The ripple of a change is what an agent has to hold in context and what a reviewer has to read. An import is usually the shortest way to a working change, so agents add them freely, and no test fails when the graph gets tangled. You need a separate sensor for this.

Say it to your agent

“Show me every file that imports src/billing/tax.ts, directly or through another file. If that is more than five, put a narrow interface in front of it, so callers depend on the interface and nothing else.”

Separation of concerns

Keeping different kinds of decision in different places: what the tax rule is, how it is stored, how it is shown. Then you can think about one, and change one, without reading the others.

Asked to add a field, an agent will happily put reading the input, the business rule and the database code into one function, because that works. The next change to the rule then means reading all of it, and testing it means starting a server.

Say it to your agent

“This route handler does parsing, the discount rule and the SQL. Move the rule to src/pricing.ts as a pure function with no database access, and test it without starting the server.”

Separating things only helps if the separate pieces hide something. That act of hiding has a name, and it is where most design goes right or wrong.

Abstraction

A simpler way to think about something complicated, leaving out what you don't need right now. save(user) lets you forget about disks and SQL. Every well-named function is a small abstraction.

A good one saves an agent reading: it needs the interface and not the inside, which keeps its context small. But agents create them for almost nothing, and a wrong one costs more than none, because everything then has to bend around it.

Say it to your agent

“Add an abstraction here only if it lets callers forget something. Write out what a caller no longer needs to know. If the honest answer is nothing, delete the wrapper and call the library directly.”

Leaky abstraction

An abstraction whose hidden detail shows through anyway, usually at the worst moment. The interface promises something simple, and what sits underneath forces you to care about it after all.

An agent works from the interface it can see. If real behaviour depends on something the interface doesn't mention, it will write code that passes against a fake and fails against the real thing, with a clean diff and green tests.

Say it to your agent

“Our cache interface hides Redis, which stores JSON, so dates come back as strings. Add a contract test that runs the same cases against the in-memory fake and a real Redis, and make the fake serialise too.”

Interface

Everything one module must know to use another: function names, what they take, what they return, what can go wrong, and any rule that isn't written down anywhere.

The interface is what an agent reads instead of the code behind it, so it is the part most worth getting right. Types make part of it checkable by machine. The unwritten part is where agents go wrong, because they can't read what nobody wrote.

Say it to your agent

“Before you write the module, write only its exported function signatures and one usage example, and show me those. I want to see what a caller has to know before any of it exists.”

Deep module

Also called Shallow module

A module that does a lot behind a small interface. A shallow one has an interface nearly as complicated as what it does, so using it spares you very little. The words are John Ousterhout's.

A deep module means less to hold in context per task, and one obvious place to make a change. Asking an agent for small pieces without asking for depth can get you the opposite: every helper correct, and the caller left doing the real work.

Say it to your agent

“Merge these six exported cache helpers into one function: cached(key, ttlMs, compute). Expiry and the key format become private. Callers must not know either exists.”

From here come the rules most people know by name. Treat every one of them as advice from someone experienced, because that is what it is.

Single responsibility

Also called SRP

The rule that a module should have one reason to change. "Reason" is the word people miss: Robert Martin means one group of people whose requests cause the change, which isn't the same as doing one task.

Read as "do one thing", this rule can justify endless splitting. Read Martin's way, it tells an agent where a line belongs: between things that different people will ask to change.

Say it to your agent

“Invoice.ts changes when finance changes a tax rule and when support changes email wording. Split it so each of those requests touches one file: src/tax.ts and src/invoice-email.ts.”

SOLID

Five design rules for code built from classes, under one acronym: Single responsibility, Open-closed, Liskov substitution, Interface segregation and Dependency inversion. They give you words for arguing about design.

Asking an agent to "apply SOLID" is a good way to get more classes. Ask for the one letter you need and say why. In a small codebase made of plain functions, several of the letters hardly apply.

Say it to your agent

“Don't apply SOLID in general. Tell me which one of its five rules this change breaks, on which line, and which future change that makes expensive. If you can't name that change, leave the code alone.”

Now the most repeated advice in programming, and among the most misapplied.

DRY

Also called Don't repeat yourself

Don't repeat yourself: each piece of knowledge should live in one place, so changing it means changing it once. It is about knowledge and not about text. Two lines can look alike and mean different things.

A session that doesn't know a helper exists may write it again. The classic result is a fix that lands in three of four copies, with the fourth going wrong months later. When a rule has one home, an agent can't miss a copy.

Say it to your agent

“The 20% tax rate is written in four files. Make one TAX_RATE in src/tax.ts and import it everywhere. Then grep for 0.2 and 1.2 and show me that those results have gone.”

Rule of three

Also called Three strikes and you refactor

Put up with the first duplicate. When you are about to write the same thing a third time, extract it. By then you have enough examples to see what is really shared.

An agent told to value DRY may extract a shared helper at the second copy, and it won't be around to feel the result. A wrong abstraction is expensive to undo, since each new case bolts on another flag and the helper gets harder to read.

Say it to your agent

“These two functions look alike, but quotes and invoices change for different reasons. Leave them duplicated. If a third case appears, show me all three before extracting anything.”

YAGNI

Also called You aren't gonna need it

You aren't gonna need it: don't build something now because you guess you'll want it later. Build it when you do want it.

Speculative code used to cost you the typing, and that was the brake. An agent writes it for free, so the brake has gone and the rest of the cost hasn't. Every later session has to read it and work around it.

Say it to your agent

“Remove the plugin system and the config option for other export formats. We export CSV. Nobody has asked for anything else. Keep one function and its test.”

KISS

Also called Keep it simple

Keep it simple, stupid. Prefer the plainest design that does the job, because someone will have to fix it under pressure one day, without its author there to explain it.

An agent doesn't pay for cleverness when it writes, and the next session reads it cold. On its own this rule is too vague to enforce, so turn it into something specific that you can check.

Say it to your agent

“Two nested generics and a factory for one caller is too much. Rewrite this as a plain function with explicit arguments. If a new reader needs more than a minute to follow it, it isn't finished.”

Some of what makes code hard to change is what it reaches out and grabs for itself.

Dependency injection

Also called DI

Passing a function the things it needs, so it doesn't have to go and get them. isExpired(token, now) can be tested at any date. A function that reads the real clock inside can only be tested today.

Code that reaches for the real clock, network or database leaves an agent two choices: mock everything or skip the test. With injection, the honest test is also the easy one, so that is the one you tend to get.

Say it to your agent

“isExpired reads Date.now() inside, so the test can't control time. Add a now parameter that defaults to Date.now(), and add tests for one second before and one second after expiry.”

Composition over inheritance

Build behaviour by combining small parts, rather than stacking parent and child classes. Want a client that logs and retries? Wrap the client in something that adds each behaviour, rather than extending it.

To change an inherited class you have to read its parents, which spends context on files you aren't changing. Parts that are combined can be read, replaced and tested one at a time, including by an agent.

Say it to your agent

“Don't extend BaseClient again. Write withRetry(client) that returns an object with the same methods, and use it only at the one place that needs retries.”

Those were rules for functions and classes. Apply the same thinking to a whole program and people start calling it architecture.

Layered architecture

Dividing a program into layers, such as interface, rules and storage, where each layer may only use the ones beneath it. The rule about direction is what does the work.

This is one of the best kinds of rule for an agent, because a check can read every import and refuse, every time. A direction rule that only lives in a document gets broken the first time an import is the shortcut.

Say it to your agent

“Add a structural test: read every import in src/ and fail if anything in src/repo imports from src/service or src/ui. The failure message must name the layer rule and tell the reader to pass the value in instead.”

Clean architecture

Robert Martin's version of layering: business rules sit at the centre and know nothing about databases or web frameworks. Those sit on the outside and depend inwards, so they can be replaced.

It hands an agent a rule simple enough to check: does this inner file import anything from outside? It also costs more files and more translating between shapes. For a small tool that price is usually too high, so ask for it when your rules will outlive your framework.

Say it to your agent

“src/domain must import nothing from express, pg or src/adapters. Add a structural test for that, define a SubscriptionStore interface inside src/domain, and implement it in src/adapters/postgres.ts.”

Boundary

A line in the code where one part hands over to another: your code and the network, one module and the next, your code and a library. What crosses it should be checked and kept narrow.

A boundary tells an agent where its change should stop, and lets several agents work at once, one on each side. It is also where untrusted data comes in, so it is the one place that checking really has to happen.

Say it to your agent

“All calls to Stripe go through src/payments/stripe.ts. Nothing outside that folder may import the stripe package, and the types that cross the line are ours and not Stripe's. Add a structural test for it.”

The strongest design rule is one the agent can't argue with, and that means putting it in the types.

Parse, don't validate

Where data enters, turn it into a type that can only hold good data, rather than checking it and passing the original along. After that nothing needs to check again, and nothing can forget to.

An agent adding a new code path may not know a check is needed. If the function takes a parsed type, it can't be called with raw input, because the type checker says no. A rule held in the types holds in every session.

Say it to your agent

“Parse the request body once in the route with a zod schema, into an Order type where quantity is a positive integer. Everything after that takes Order and not unknown. Delete the isValidOrder checks further down.”

Make illegal states unrepresentable

Design your data so that a wrong combination can't even be written down. An order that is both cancelled and shipped should be impossible to express, rather than something every function must remember to check.

Every check you have to remember is one an agent can forget. With the right data shape, the type checker catches the mistake in every session, including the ones nobody reads, and there are fewer branches to test.

Say it to your agent

“Replace the isPaid, isShipped and isCancelled booleans with one status field: a TypeScript union where shipped carries a trackingId and cancelled carries a reason. Then fix every compile error. Don't add new if-checks.”

All of that assumes you can change the shape of code safely. That is what this word means, and its meaning is stricter than most people use it.

Refactoring

Changing how code is organised without changing what it does, in small steps, running the tests after each one. If the behaviour changed, it wasn't refactoring.

Agents say "refactor" and may mean rewrite. A rewrite that also changes behaviour can't really be reviewed, and when something breaks you can't tell which line did it. Small green steps give an agent a way to know it is still safe.

Say it to your agent

“Refactor only: no behaviour changes and no test edits. Make each step its own commit and run bun test between them. If a test goes red, revert that step rather than fixing forward. Add the feature afterwards, in a separate commit.”

Code smell

A pattern in code that suggests a problem without proving one: a very long parameter list, one module reaching into another's data, the same switch in five places. It is a prompt to look, and it isn't a verdict.

Smell names are precise review vocabulary, so one word can replace a paragraph of instructions to an agent. Make it show what each one costs, though, because any model asked to find smells will find some.

Say it to your agent

“Review this diff for Long Parameter List, Feature Envy and Shotgun Surgery. For each hit, quote the lines and name the next change it makes expensive. If you can't name one, drop the finding.”

Technical debt

A metaphor: taking a shortcut now is like borrowing. You get the feature sooner, and then you pay interest on every later change until you pay it back.

An agent can borrow faster than any person, and it pays interest in a new way: it rereads the mess each session, in tokens and in wrong turns. It can also repay cheaply, which changes the sums, so cleaning up may now be worth doing far more often.

Say it to your agent

“Don't pay this down in general. List the three places that slowed our last five changes, with commit links, and fix only the worst one. Then set up a weekly agent that opens one small clean-up pull request.”

Some of this can be measured, which is useful for as long as you remember what the number is.

Cyclomatic complexity

Also called McCabe complexity

A count of the separate paths through a function: one, plus one for each fork, such as if, a loop, a case or &&. More paths mean more to understand and more tests to cover them.

It gives an agent a fast fact in place of an opinion about "too complex". It also has a blind spot you should know about: a lookup table scores 2 where the same logic as a switch scores 13, and that can be either a real improvement or a dodge.

Say it to your agent

“Add the ESLint complexity rule at 10 on src/, as an error. Where it fires, tell me whether the branches answer one question (leave it, and say why) or several (split it).”

CRAP score

Also called Change Risk Analysis and Predictions

One number combining how complicated a function is with how little of it the tests run. Complicated and untested scores terribly. Simple or well tested scores low. The formula is complexity squared, times untested fraction cubed, plus complexity.

It is a gate an agent can run and act on, because the score offers two ways out: simplify or test. And an agent will reach any number you set by the cheapest route, which may not be the route you meant.

Say it to your agent

“Run ./crapper --changed --threshold 10. For anything over 10, tell me whether it is one question (a single switch can stay) or mixed duties (split it). Don't add helpers that take booleans the caller already knew, and don't chase a lower score.”

Duplication detection

Also called Clone detection

A tool that finds code repeated across a project, usually ignoring names so that a renamed copy still counts. It hands you candidates, and you decide whether they are really the same idea.

A session that doesn't find the helper writes another, and this is the cheap sensor for that. Treat what it finds as questions, since the rule of three says some duplicates are correct.

Say it to your agent

“Run dryer on src/ and show me every pair above 0.82. For each, say whether the two change for the same reason. Merge the ones that do. Leave the ones that don't, and write down why.”

With all those words you can now say exactly what people mean by the word that gets thrown around most.

Slop

Also called AI slop

Agent output that works and that nobody would have chosen to keep: checks for cases that can't happen, comments that restate the code, helpers with one caller. The tests pass, which is how it gets in.

Each piece looks harmless, so each one gets through review. They add up, though, and the next session reads what is there and copies the style. You can't test this away, because nothing about it is failing.

Say it to your agent

“Remove everything I didn't ask for: checks for states the types already rule out, comments that restate the next line, and helpers with one caller. The tests must still pass. Show me the line count before and after.”

Agent sprawl

Also called Overbuilding

An agent building more than you asked for: extra files, options and layers for a small job. Each addition is reasonable on its own, and the total is a bigger thing to carry for as long as the code lives.

Code costs an agent almost nothing to write, so nothing in its loop pushes back on size. That force has to come from you, which is why pstack and ub-mode, two published sets of agent instructions, both carry a rule about smallness.

Say it to your agent

“Smallest change that makes this test pass. No new files, no new dependencies, no config options. If you think more is needed, stop and tell me why before you write it.”

Two more decisions that shape how far an agent can reach.

Monolith and microservices

A monolith is one program, deployed as one thing. Microservices split it into many small programs that talk over a network, each deployed by itself. One is simpler to run. The other lets teams move separately.

An agent does its best work when it can run and test the whole system from one place. Each network hop adds failures it can't see locally. Microservices mostly solve a problem of coordinating people. That is our judgement, and nobody has good data yet on how agents change the sums.

Say it to your agent

“Keep this as one deployable. Make src/billing a module with one exported interface, add a structural test so nothing reaches inside it, and we can split it out later if a real reason appears.”

Architecture decision record

Also called ADR

A short text file in the repo recording one design decision: the situation, what was decided, and what it will cost. You don't rewrite it later. A new record replaces the old one.

An agent has no memory of why. Without a record it will see something odd and tidy it away, or reopen an argument that was already settled. Code shows what was chosen, and only a record says what was rejected.

Say it to your agent

“Write docs/adr/0007-one-deployable.md with Context, Decision, Status and Consequences, including what we rejected and why. Add a line to AGENTS.md: read docs/adr/ before changing how modules are split.”

One last distinction, and it is the reason agents make this chapter matter more.

Simple versus easy

Easy means close at hand or familiar. Simple means not tangled together. The two are independent: a tool can be easy to start with and leave you a knot. Rich Hickey drew this line in a 2011 talk.

Agents make nearly everything easy. A working feature is one sentence away, whatever it tangles together. So "easy" has stopped telling you anything, and "simple" is what is left to judge. You still have to ask for it.

Say it to your agent

“This works, but caching and pricing are tangled: I can't change one without reading the other. Separate them so the price function knows nothing about the cache. The test is whether each can be changed without opening the other.”

Do it for real

Reading gets you the words. This gets you the experience behind them, and something to show.

Argue with a number

Running a metric takes a minute. Being able to say where it is right and where it is wrong is what impresses a sceptical senior engineer.

  1. Take a real project of yours and run a complexity tool on it: ./crapper from unclebob/crapper if your language is covered, or the ESLint complexity rule.
  2. Pick the three worst functions and read them. For each, write one line: is the score telling the truth, and which next change would it make expensive?
  3. Have your agent bring one function under 10 with tests or a split. Read the result as if someone else had written it.
  4. Write down whether it got easier to read. If it didn't, revert it and say why.

Done when: A short note in your repo with three functions, their before and after scores, and one honest verdict, including any case where the metric was wrong.

Price a change before you make it

This turns "good code is cheap to change" from a slogan into something you measured on your own code, and it is a clear story to tell in an interview.

  1. Choose a realistic future request, such as a new tax rule, a new role, or another currency.
  2. Ask your agent to list every file and line it would need to touch, without changing any.
  3. Run git diff --stat after it really makes the change on a branch, and compare that with its list.
  4. Now refactor so that the same kind of change touches one place, in small green commits. Make a second request of the same kind and count again.

Done when: Two git diff --stat outputs for the same kind of change, before and after, in a pull request with small commits.

Record one real decision

Agents have no memory of why. A decision record stops the next session undoing your choice, and shows a reader how you think.

  1. Find a decision in your project that a newcomer might "fix" by mistake.
  2. Write docs/adr/0001-short-name.md with Context, Decision, Status and Consequences, including what you rejected.
  3. Add one line to AGENTS.md telling the agent to read docs/adr/ before changing that area.
  4. Start a fresh session, ask it to change that area, and see whether it mentions the decision.

Done when: A committed decision record, and a transcript where a fresh agent session respects it or a note on why it didn't.

The build, stage 11 of 18

A seam, and two decisions

Complexity and duplication gates, all the SQL behind one interface, a test that keeps it there, and two decision records a later session obeyed.

About 120 to 180 minutes, on the same app as every other stage. We ran it first and show what went wrong.

Open stage 11 of the build

Gate 11: Design

0 of 6 right

  1. 1An agent reports that it took a file from a worst CRAP score of 18 down to 3. The diff has tripled the number of functions, and several new helpers take booleans that their one caller already knew. Tests are green. What should you do?

  2. 2An interviewer asks: "Short functions are obviously better, right?" What is the strongest answer?

  3. 3A bug report shows an order that is both cancelled and shipped. The order has three boolean fields, and the agent proposes adding an if-check at the top of the ship function. What is the better fix?

  4. 4A diff contains two 12-line functions that look almost the same: one builds a quote email, the other an invoice email. There are only those two. The agent has merged them into sendEmail(order, isQuote). What do you say?

  5. 5All your tests use an in-memory fake of your cache. In production, using Redis, a date read from the cache throws "getFullYear is not a function". The interface is correct and hasn't changed. What has happened?

  6. 6You ask for a one-off CSV export. The agent returns a plugin system with a formatter interface, a registry and config for PDF and XML that nobody requested. Everything works and has tests. What is the right response?

Get every answer right to install it, and to put 3 files in your repo. Wrong answers cost nothing.

Next chapterReview

The workshop

Name a word, or describe what you mean in everyday language.