Small functions, one job
A function should do one thing — have one reason to change — at one level of abstraction; extracting the sub-intents and naming each step is the win, not hitting a line count, and over-extraction is as harmful as a god function.
Open a typical request handler and you find forty lines that parse the body, validate the fields, write to the database, send an email, and format a response — all in one breath. It works. But to find the one line you care about, you have to read all forty and mentally re-derive which lines belong to which concern, every time. The function has no shape; it is a wall of “and then, and then, and then.”
The fix everyone reaches for is “make functions small.” That advice is right but for the wrong reason. The point is not fewer lines — it is that a function should do one thing, and “one thing” has a precise meaning a senior engineer can defend: one reason to change, expressed at one level of abstraction. Get that wrong in the other direction and you shred cohesive logic into a dozen one-line functions you chase across the file. This lesson is about hitting the middle.
After this lesson you can define “one thing” by reasons-to-change and cohesion rather than by line count; take a long handler and extract its sub-intents into named functions so the top function reads as a list of named steps; explain why naming the step is the actual payoff (each extracted function documents an intent); and recognise over-extraction — scattering one cohesive idea across many trivial functions — as the failure mode that mirrors the god function.
“One thing” means one reason to change, at one level of abstraction — not one line. A handler that parses, validates, persists, and notifies has four reasons to change: the wire format, the business rules, the storage schema, and the messaging channel. Each can move independently. When they share one function body, a change to any of them forces you to read past the other three and risk touching them. The smell is not the length — it is that the lines operate at different altitudes: high-level orchestration (“then we notify the user”) sits inline next to low-level detail (new Date().toISOString(), SMTP options). Your eye has to keep changing gears.
// one function, four reasons to change, mixed altitudes
async function handleSignup(req: Request): Promise<Response> {
const body = await req.json();
if (!body.email || !body.email.includes("@")) return bad("email");
if (!body.password || body.password.length < 8) return bad("password");
const id = crypto.randomUUID();
await db.execute("INSERT INTO users(id,email,pw) VALUES(?,?,?)",
[id, body.email, await hash(body.password)]);
await mailer.send({ to: body.email, subject: "Welcome", body: tmpl(id) });
return new Response(JSON.stringify({ id }), { status: 201 });
}Extract each sub-intent into a named function so the top function reads as steps. The transform is mechanical: find a contiguous run of lines that serves one purpose, lift it into a function, and name that function after the purpose, not the mechanism. After extraction, the handler stops being a wall and becomes a short list of intents at one altitude.
async function handleSignup(req: Request): Promise<Response> {
const input = await parseSignup(req); // wire format
const error = validateSignup(input); // business rules
if (error) return bad(error);
const user = await persistUser(input); // storage
await sendWelcome(user); // messaging
return created({ id: user.id });
}Now the top function is four named steps. To change validation you open validateSignup and read nothing else. To change the email you open sendWelcome. The orchestration reads like a sentence; each detail lives one level down, where it belongs.
The win is the name, not the smaller body. This is the part people miss. validateSignup is valuable even if it is only three lines, because the name asserts an intent: “this run of code is the validation step.” The extracted function is documentation that the compiler keeps honest — it can’t drift from the code the way a comment can. A good extraction replaces a comment-plus-block with a named call:
// before: the comment is the only thing telling you what this block is
// validate the signup input
if (!body.email || !body.email.includes("@")) return bad("email");
if (!body.password || body.password.length < 8) return bad("password");
// after: the name carries the same intent, and can't go stale
const error = validateSignup(body);
if (error) return bad(error);If extracting a block doesn’t let you give it a name that’s more informative than the lines inside it, the extraction is buying nothing. The test for “is this a real sub-intent” is: can I name it after a concept the reader already cares about? “Validate signup”, “persist user”, “send welcome” — yes. “Lines 12 through 17” — no.
Over-extraction is the equal-and-opposite failure: shredding cohesion into a function-chase. Once “small is good” becomes a reflex, people extract one-liners that have no independent intent — function isEmpty(s) { return s.length === 0 }, function addOne(n) { return n + 1 } — and split a single cohesive computation across six functions you must hop between to understand. Now reading one idea means chasing six definitions across the file, holding the call stack in your head. That is less cohesive, not more: you’ve increased coupling between fragments while destroying the locality that let you read the thing top to bottom.
// over-extracted: one cohesive formula, scrambled across the file
function priceFor(item: Item): number {
return applyTax(applyDiscount(base(item))); // must chase 3 defs
}
function base(i: Item) { return i.qty * i.unit; }
function applyDiscount(x: number) { return x * 0.9; } // 0.9? for whom?
function applyTax(x: number) { return x * 1.2; } // hidden magicThese fragments don’t have independent reasons to change — discount, tax, and base price are one pricing rule. Inlined, the whole rule reads in four lines in one place. The heuristic: extract when the fragment has its own reason to change or is reused; don’t extract just to make the parent shorter. Cohesion — things that change together living together — is the target on both sides.
Extract a 40-line handler into four named steps — and stop at four. Start from the god function in Step 1: it parses, validates, persists, and notifies inline. Each is a real sub-intent with its own reason to change, so each earns a named function:
// parse.ts — owns the wire format
function parseSignup(req: Request): Promise<SignupInput> {
return req.json() as Promise<SignupInput>;
}
// validate.ts — owns the business rules; returns the first problem or null
function validateSignup(input: SignupInput): string | null {
if (!input.email?.includes("@")) return "email";
if ((input.password?.length ?? 0) < 8) return "password";
return null;
}
// users.ts — owns storage
async function persistUser(input: SignupInput): Promise<User> {
const user = { id: crypto.randomUUID(), email: input.email };
await db.execute("INSERT INTO users(id,email,pw) VALUES(?,?,?)",
[user.id, user.email, await hash(input.password)]);
return user;
}
// notify.ts — owns messaging
async function sendWelcome(user: User): Promise<void> {
await mailer.send({ to: user.email, subject: "Welcome", body: tmpl(user.id) });
}
// handler.ts — owns orchestration only
async function handleSignup(req: Request): Promise<Response> {
const input = await parseSignup(req);
const error = validateSignup(input);
if (error) return bad(error);
const user = await persistUser(input);
await sendWelcome(user);
return created({ id: user.id });
}Each function does one thing at one altitude, and its name documents the step. Notice where we stopped: we did not extract function isValidEmail(e) { return e.includes("@") } out of validateSignup. The two checks inside validateSignup are one cohesive idea — “are these signup fields acceptable” — and they change together. Splitting them would buy a shorter body at the cost of a function-chase, the over-extraction from Step 4. Four steps, four reasons to change, and validation reads in one place: that is the shape to aim for.
▸Why this works
Why is “one reason to change” the right definition instead of a line limit? Because line count is a proxy that breaks at both ends. A 60-line function can legitimately do one thing — a single state machine, a parser, a layout calculation — where every line serves one cohesive purpose and extracting pieces would only obscure it. And a 6-line function can do three things badly. Counting lines optimises a symptom; counting reasons to change optimises the actual property you care about (the cost of the next change, from this unit’s first lesson). When the reasons-to-change test and the line-count instinct disagree, trust reasons-to-change — it is what cohesion and the single-responsibility principle actually measure.
▸Common mistake
The seductive mistake is treating “extract function” as always-good and racing the line count to zero. It produces code that looks disciplined in any single function and is miserable to read as a whole, because understanding one behaviour now requires reconstructing a call tree spread across the file. A reviewer’s tell: if you have to jump to a definition to learn something a well-named inline expression would have told you in place, the extraction subtracted value. Extraction is a tool for naming intents and isolating reasons to change, not a score to maximise. Stop when each function names a concept the reader cares about — and not one slice sooner or later.
A 50-line function implements a single pricing state machine: every line participates in one cohesive calculation that changes only when the pricing rules change. A teammate insists it must be split because 'functions should be under 20 lines'. What is the senior call?
A function should do one thing, and “one thing” is defined by one reason to change at one level of abstraction — not by a line count. Take a god handler that parses, validates, persists, and notifies and extract each sub-intent into a named function; the top function then reads as a short list of steps, and the real payoff is that each name documents an intent the compiler keeps honest. But the principle has a failure mode at the other end: over-extraction, where you shred one cohesive idea into a dozen trivial one-liners and turn reading into a function-chase across the file — as change-hostile as the god function. The governing target on both sides is cohesion: code that changes together lives together. Extract when a fragment has its own reason to change or is reused; otherwise leave it inline and readable in place.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.