Clean code habits for design interviews
Open in the interactive app: practice, quiz, mock interview →In one minute
- Clean code is code the next reader can change safely without asking you. It is judged by the reader's time and the defect rate after changes, not by how it looks.
- Names carry most of the meaning. A name should say what a thing is or does at the level its caller thinks, and its length should grow with its scope:
iin a 3-line loop,pendingRefundsByCustomeracross a module. - Functions should do one thing at one level of abstraction, take few parameters, avoid boolean flags, and keep nesting shallow with guard clauses.
- Make errors and side effects visible: specific errors returned or raised at the point of failure, no silent
None, and a clear split between functions that compute and functions that act. - Delegate formatting to tools and measure the rest. Linters catch length, nesting and parameter counts; review is for names, boundaries and whether the code says what the domain means.
How it works
Who clean code is for
Code is read many more times than it is written: in review, when debugging at 2 a.m., when someone extends it next year, and now when an agent reads it to make a change. Each reader has to rebuild your mental model from the text alone. Clean code minimises that rebuild cost. Every rule below is a way to put more of the model into the code and less into the reader's head.
Names
Names are the cheapest documentation and the one most often read.
- Say what it means in the domain.
eligible_for_refundbeatscheck2;retry_after_secondsbeatsdelay. Units belong in the name when the type does not carry them. - Scale length with scope. A loop index used in three lines can be
i. A field visible across a package needs a full phrase. A one-letter name at package scope is a riddle; a 40-character name inside a two-line lambda is noise. - Name functions by effect or result. Functions that act get verbs (
cancel_booking), functions that compute get nouns or questions (total_due,is_expired). A function namedget_xshould not write anything. - One word per concept. If the codebase says
fetchfor remote reads, do not addretrieveandloadfor the same thing. Readers assume different words mean different things. - No encodings. Hungarian prefixes,
Ion interfaces in Go,Implsuffixes andManagerorHelperclasses tell the reader nothing about behaviour.
Functions
A function is clean when a reader can predict what it does from its name and signature, and confirm it by reading the body once.
- One thing, one level.
place_ordershould read like the steps of placing an order: validate, reserve stock, charge, record. Parsing a date string in the middle of it drops the reader three levels down. - Few parameters. Three is a lot. Five usually means a missing concept:
(street, city, zip, country)is anAddress. Long parameter lists also invite argument-order bugs, which named arguments or a small options struct prevent. - No flag arguments.
render(doc, True)tells the reader nothing, and the function now does two things. Split it intorender_preview(doc)andrender_final(doc), or pass an enum if there are genuinely more modes. - Shallow nesting. Handle the error and edge cases first and return early (guard clauses), so the main path runs down the left margin. Nesting beyond two or three levels is the clearest sign a function wants to be split.
- Command-query separation. A function either answers a question or changes state, not both.
pop()is a deliberate exception; most are accidents (validate()that also normalises and saves).
How long is too long? Length is a symptom, not the disease. A 40-line function that reads top to bottom as one sequence is fine; a 12-line function with four levels of nesting is not. A better test, from Martin Fowler: if you have to read the body to know what a block does, extract it and name it after its intention.
Errors and side effects
- Fail at the point of failure, specifically. Raise or return
InsufficientCredits(needed=3, available=1), notFalseorNone. The caller should be able to decide what to do from the error alone. - Do not hide failures.
except Exception: passand_ = errturn bugs into data corruption that surfaces days later. - Keep side effects at the edges. Compute in pure functions (easy to test, safe to reuse), then perform I/O in a thin layer that calls them. Separation of Concerns covers the structure; the practical rule is that a function that reads the clock, the database or the network should say so in its name or its parameters.
- Inject what varies. Time, randomness and I/O passed in as parameters make behaviour deterministic and testable, which is why every practice task in this course injects a clock.
Comments
Write comments for what the code cannot say: why this approach, which constraint forced it, what was tried and failed, links to the incident or spec. Do not narrate the code (# increment i). When you need a comment to explain what a block does, extract the block into a function whose name is that comment. Keep public API documentation accurate; a wrong comment is worse than none.
Structure and data
- Prefer immutable data and small value types (
Money,TimeRange) to loose primitives. They carry their rules with them; How to Identify Entities and Model Relationships covers when to create one. - Replace a repeated
switchon a type code with polymorphism or a table, once the same switch appears in three places (How to choose Design Patterns goes into the decision). - Keep a file or class about one thing. If its name needs "and" or "Utils", it holds unrelated concerns.
Consistency and tools
Formatting arguments are a waste of review time. Use the formatter your ecosystem standardised on (gofmt, black or ruff format, prettier) and run it in CI. Use a linter for mechanical rules (unused variables, shadowing, complexity limits) so reviewers spend their attention on names and design. In an existing codebase, local consistency beats your preferred style: a reader who learns one pattern should find it everywhere.
Clean code with coding agents
Agents produce code that looks clean at a glance and fails on substance: long defensive blocks that catch every exception, comments restating each line, generic names like data, result and handle, and new helper functions duplicating ones that already exist two files away. They also follow what they see. A codebase with clear names, small functions and a lint config teaches the agent the house style; a messy one gets messier faster. Put your conventions in the repository (lint config, a short style note), and review generated code for names, error handling and duplication rather than formatting.
The same shape elsewhere
Clean code is the software version of plain-language writing rules used in law, medicine and aviation: short sentences, one idea per sentence, specific nouns, consistent terms, and the important thing first. A checklist in a cockpit and a clean function share a design goal: a tired reader under pressure must not misread it. The practice task builds the mechanical half of that, a small linter that flags long functions, deep nesting, long parameter lists, flag arguments and cryptic names.
Down the stack
Layer 1The contract
Clean code makes promises to the reader that you can check. Names match behaviour: a query does not mutate, a function named save persists. A function's signature lists everything it depends on that can vary (no hidden clock or global). Errors are explicit and specific. Mechanical limits hold across the codebase, for example at most 4 parameters, nesting at most 3, functions at most 50 statements. Those limits are proxies, but they are testable in CI, which turns "write clean code" from advice into a gate.
Layer 2The mechanism
Linters parse source into an abstract syntax tree and walk it. Cyclomatic complexity, from Thomas McCabe's 1976 paper, counts independent paths through a function's control-flow graph: edges minus nodes plus 2, in practice one plus the number of decision points (if, loop, case, &&, ||). Cognitive complexity, defined by SonarSource, adds a penalty for each nesting level, which matches how hard code feels to read better than raw path counts. Tools set defaults: Pylint flags more than 5 arguments (too-many-arguments), more than 50 statements and more than 5 nested blocks; ESLint's max-params defaults to 3 and max-depth to 4; gocyclo and Ruff's C901 report McCabe complexity above a threshold you pick. Formatters such as gofmt parse to an AST and print it back in one canonical layout, which is why they can never disagree with themselves and why diffs only show real changes.
Layer 3The machine
Clean code is mostly free at runtime. Small functions get inlined: Go's compiler inlines functions under a cost budget of 80 AST nodes, the JVM's JIT and V8's TurboFan inline hot small methods, so extracting a helper usually costs nothing after compilation. CPython does not inline: a Python function call costs roughly 50 to 100 ns, which matters only in loops running millions of times, where you would optimise deliberately anyway. The machine that clean code really optimises for is the reader's brain. Working memory holds about four chunks at once, so a function that needs the reader to track six variables, three nesting levels and a flag overflows it, and the reader starts scrolling back. Code review has a similar limit: reviewers find defects well in about 200 to 400 lines at a sitting and miss them beyond that, so smaller, clearer changes get better reviews.
Why it's built this way
- Code lives for years and is changed by people (and agents) who did not write it, so the cost that dominates is reading, not typing.
- Readers can hold only a few things in mind at once, so functions are kept small and shallow, with few parameters.
- Most of what a reader needs is in names and signatures, so naming and explicit dependencies get the most care.
- Hidden failures and side effects cause the most expensive bugs, so errors and I/O are made explicit and pushed to the edges.
- Humans argue about style forever, so the mechanical part is handed to formatters and linters, leaving review for judgement.
Diagram
flowchart TD
W["Write or generate code"] --> F["Formatter (gofmt, black, prettier)"]
F --> L{"Linter limits pass?"}
L -- "too long, deep, many params" --> R["Extract function, guard clauses, value object"]
R --> F
L -- "yes" --> T{"Tests pass?"}
T -- "no" --> W
T -- "yes" --> V["Human review: names, errors, boundaries"]
V -- "unclear intent" --> R
V -- "approved" --> M["Merge"]Worked example
A booking service has this function, written under deadline pressure:
def proc(d, items, rate, region, dry_run=False):
total = 0
for it in items:
if it.qty > 0:
if it.sku in d:
p = d[it.sku]
if region == "EU":
p = p * (1 + rate)
total += p * it.qty
if not dry_run:
db.save_invoice(total)
return totalProblems: the name says nothing; d, p and it are cryptic at this scope; five parameters; a flag argument that makes the function both compute and persist; nesting four deep; unknown SKUs are silently skipped, so a typo produces an invoice that is too small; and money is a float. Rewritten:
def invoice_total(lines, prices, tax_rate_bps):
total_cents = 0
for line in lines:
if line.quantity <= 0:
continue
if line.sku not in prices:
raise UnknownSku(line.sku)
total_cents += prices[line.sku] * line.quantity
return total_cents + total_cents * tax_rate_bps // 10_000
def issue_invoice(lines, prices, region, invoices):
total = invoice_total(lines, prices, tax_rate_for(region))
invoices.save(total)
return totalinvoice_total is pure, takes three parameters, uses integer cents and basis points, and fails loudly on an unknown SKU. The region rule moved into tax_rate_for, so adding a second taxed region is one table entry. The dry run is now simply calling invoice_total without issue_invoice. Nesting dropped from four to two. Performance did not change in any way that matters: the extra call costs nanoseconds against a database write of about 2 ms. The bug class "typo yields a cheap invoice" is gone.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Many small functions | Each is named, testable, easy to reuse | Readers jump between definitions; names must be good | Logic has distinct steps worth naming |
| One longer linear function | Reads top to bottom, no jumping | Harder to test parts; grows into a mess | A short script or a straight sequence with no reuse |
| Strict lint limits in CI | Consistent; frees review time | Rules fire on reasonable code; people add suppressions | Larger teams and agent-generated code |
| Guidelines only | Flexible, no false positives | Drift; arguments in review | Small, senior teams |
| Comments explaining why | Captures context code cannot | Rots when code changes | Non-obvious constraints, workarounds, links to incidents |
| Self-describing code instead of comments | Cannot go stale | Cannot explain why | Explaining what the code does |
Pitfalls in production
- Cleaning without tests. A "harmless" rename or extraction changes behaviour (an early return skips a side effect). Fix: get characterisation tests in place before refactoring, and keep refactoring commits separate from behaviour changes.
- Abstraction for its own sake. Every two lines extracted into a helper, each used once, with names no better than the code. Fix: extract when the name adds meaning or the block repeats; otherwise inline.
- Swallowed errors.
try: ... except Exception: log.info(...)keeps the service up while corrupting data. Fix: catch specific errors you can handle; let the rest propagate to a boundary that fails the request. - Lint suppression creep.
# noqaand//nolintscattered until the linter checks nothing. Fix: require a reason on every suppression and count them on a dashboard. - Style churn in diffs. Reformatting mixed with logic changes hides the real change from reviewers. Fix: run the formatter on the whole repository once, in its own commit, then enforce it in CI.
- Names that lie after change.
get_usernow also creates the user if missing. Fix: rename in the same change that alters behaviour; treat a stale name as a bug.
Leadership lens
- Make the mechanical rules automatic (formatter, linter, limits in CI) so review time goes to design. Arguing about braces in review is a process failure.
- Measure change cost, not beauty: time to first review, review rounds, and how often a file appears in bug fixes. Hotspot files with high churn and high complexity are where clean-up pays.
- Budget refactoring explicitly, attached to features that touch the code ("leave it better than you found it"), rather than as a separate project that never gets prioritised.
- Write the house style down, short, in the repository. Agents and new hires both learn from it, and it settles disputes before they start.
- Hold agent-generated code to the same bar. Volume is not progress if every diff adds duplicated helpers and swallowed errors.
Check yourself
Why is
send_email(user, True)a problem, and what would you do instead?Show answer
The boolean is meaningless at the call site and means the function does two things. Split it into two well-named functions, or use a named enum if there are more than two modes.
When is a one-letter variable name acceptable?
Show answer
When its scope is a few lines and its meaning is conventional, such as a loop index or the parameter of a short lambda; name length should grow with scope.
A function is 15 lines but has four levels of nesting. Is it clean by length? What would you change?
Show answer
Length is fine but nesting is not: use guard clauses for the edge cases and extract the inner loop body into a named function, so the main path reads at one or two levels.
Does extracting many small functions slow code down?
Show answer
Rarely. Compiled languages and JITs inline small functions, and even in CPython a call costs tens of nanoseconds, which matters only in hot loops that you would profile and optimise deliberately.
Watch
- Clean Code, Lesson 1 (Robert C. Martin): the first of Uncle Bob's lectures on names, functions and why code is read more than written.
- All the Little Things (Sandi Metz, RailsConf 2014): a live refactoring of a tangled conditional into small, named pieces, with the reasoning at each step.
Free resources
- Function Length (Martin Fowler): the "separate intention from implementation" test for when to extract a function.
- Flag Argument (Martin Fowler): why boolean parameters hurt call sites and what to use instead.
- What to look for in a code review (Google engineering practices): Google's reviewer guide on design, complexity, naming and comments.
- Go Code Review Comments: concrete naming, error handling and interface conventions for Go.
- A Complexity Measure (Thomas McCabe, 1976): the original paper on cyclomatic complexity, the metric behind most complexity linters.