Long Functions
Long is not automatically bad. Ask whether it mixes responsibilities, whether the control flow can be followed, and whether it hides concepts that deserve names.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
This function is three hundred lines. Is that a problem, and how would I know?
A reviewer blocks a pull request because a function is 180 lines. The author says it is a linear sequence with no branching and that splitting it would make it harder to follow. Both are experienced and neither is obviously wrong.
Functions should be short. Twenty lines is a lot, ten is better, and if a function is long you extract until it is not. It is simple, it is checkable, and it settles arguments.
It is satisfiable by moving lines rather than by decomposing. Fifteen small functions that all read and write the same six fields are one function with a call graph in front of it — the state is still shared, but now it is shared invisibly (Shared-State Coupling).
- It is satisfiable by moving lines rather than by decomposing. Fifteen small functions that all read and write the same six fields are one function with a call graph in front of it — the state is still shared, but now it is shared invisibly (Shared-State Coupling).
- It creates single-use functions whose only caller is the one above them, so a reader following the logic now jumps between fifteen locations to reconstruct a sequence that used to be readable top to bottom.
- It says nothing about the properties that actually make code expensive: hidden inputs, mixed responsibilities, unclear failure behaviour. A ten-line function can have all three.
- As requirements change, the rule pushes each new case into a new tiny function, and the module accumulates a vocabulary of names that correspond to no domain concept —
handleStepTwoB— which is harder to navigate than the long function was.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The team needs an answer they can apply in review this week, not a philosophy. Whatever replaces the line limit has to be as fast to apply.
- The codebase already contains both kinds: a 400-line request handler that everybody dreads, and a 250-line query builder nobody has ever had trouble with.
- Whatever the standard is, it has to survive a new engineer who has read a popular book on the subject and arrives with a different one.
- Whatever the length, a reader must be able to determine what the function does without holding more than a few live values in their head at once.
- A restructuring done for readability must not change behaviour — otherwise it is a rewrite wearing a refactor's name (What Refactoring Actually Is).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The reviewer owns asking the three questions below rather than reporting a number. "This is long" is an observation; "these two halves change for different reasons" is a finding.
- The author owns being able to name the chunks. If a section of the body has a name, that is evidence a concept is hiding there; if it genuinely does not, that is evidence against splitting it.
- The team owns the standard, which means writing down what they will actually block on — because an unwritten standard becomes whoever reviewed it that day.
- The boundary that matters is the one between reasons to change, not the one between screens of code. Length only becomes a boundary question when two reasons to change are inside one function.
- A function boundary is not free: it is a name, an interface and a place where state has to be passed explicitly or shared implicitly. Creating one is a design decision with a cost (Over-Decomposition).
- Extracting a chunk that needs eight local variables produces an eight-parameter function or a shared mutable object, and both are worse than the chunk was. That resistance is information about where the seam is not (Finding Seams).
Two ways to split the same function
The argument is usually framed as long against short, which is why it never resolves. The useful framing is: split by responsibility, or split by length — because those produce very different code and only one of them makes the next change cheaper.
Price a real requirement against both. A 200-line checkout handler, and the requirement is a second payment provider.
Accept a second payment provider for customers in one region, with different failure codes and a different capture flow, without changing anything about how orders are priced or confirmed.
The provider logic is spread across four of the fifteen methods, which also touch pricing and the confirmation email through the shared fields. The change means reading all fifteen to find out which four, and the tests cannot exercise the payment path without a priced cart, so every payment test is an end-to-end test.
The provider is entirely inside Payment, which takes a priced cart and returns a result. Its tests need no cart-building and no email stub. Nothing about pricing or confirmation is in the diff, so nothing about them needs re-reading.
Long function, as a smell rather than a verdict
Treat it the way you would treat any smell: as a prompt to look, with a clear statement of when the code is actually fine. The "fine" case here is not rare, which is exactly why the numeric rule causes harm.
- A comment banner that says
// --- pricing ---inside a function body is the author telling you where a boundary is (Comments). - Count live variables at the widest point rather than lines: a 300-line function with three locals is easier than a 60-line one with twelve.
- Check the file's change history. Three teams editing one function this quarter is a stronger finding than any measurement of the code (Divergent Change).
looks like One function well past what the surrounding code averages — a few hundred lines, often with sections separated by blank lines or comment banners.
suggests Possibly several responsibilities in one place; possibly deep nesting and a large set of simultaneously live variables; possibly a domain concept with no name. Possibly none of these.
fix Run the three questions. If it mixes responsibilities, split along them and expect units of tens of lines, not units of five. If control flow is the problem, flatten it with guard clauses first — that often ends the conversation without any extraction. If a concept is hiding, name it. If none apply, close the review comment.
switch, or a generated function. All of these are read once from top to bottom, gain nothing from being split, and lose the property that everything relevant is visible at once.The split that made it worse
This is the failure the numeric rule reliably produces, and it is worth seeing concretely because it always looks like progress in the diff. Every function is small, every name is fine, and the code is harder to change than it was.
class OrderProcessor {
private cart!: Cart
private total!: Money
private tax!: Money
private charge!: ChargeResult
process(c: Cart) {
this.cart = c
this.validate(); this.computeTotal(); this.applyTax()
this.chargeCard(); this.persist(); this.notify()
}
// six methods, each under 20 lines, each reading and
// writing the fields above. Order of calls is load-bearing
// and written down nowhere.
}function process(cart: Cart, deps: Deps): Order {
const priced = price(cart) // pure, own tests
const charge = deps.payment.charge(priced) // one concern
const order = deps.orders.save(priced, charge)
deps.confirmation.send(order)
return order
}
// Four units, 40-60 lines each. No shared mutable state,
// no load-bearing call order beyond data dependencies.The left version has six functions and one shared mutable state, so a reader must reconstruct the temporal coupling between them to understand anything, and a wrong call order is a runtime failure rather than a compile error. The right version has fewer, larger functions and no shared state: each value is produced by one step and consumed by the next, which is visible in the signatures. Both satisfy "small functions". Only one made the next change cheaper.
How to build it
Most important first.
- Ask whether it mixes responsibilities. Write down what would make you edit this function; if the list contains items owned by different teams or different parts of the domain, split along those lines regardless of length (Single Responsibility, Carefully).
- Ask whether the control flow can be followed. Nesting depth, the number of variables live at once, mutation of a value far from its declaration, and early exits scattered through a loop are what make code hard to hold — and all four can be fixed without extracting anything (The Complexity Budget).
- Ask whether meaningful concepts are hidden. If you can name a fifteen-line chunk in domain words — "this part decides the proration" — the name is worth having, and that is a genuine extraction rather than a length-driven one.
- If all three answers are no, leave it long. A linear sequence with no branching, no shared mutable state and no hidden concept is exactly as hard to read at 200 lines as at 20, because you read it once, top to bottom.
- Prefer reducing nesting to extracting: guard clauses, early returns and inverting conditions often make a long function readable without adding a single name (Extract Function).
- When you do extract, extract along a seam that would exist anyway — something with a domain name, its own tests and a plausible second caller. Extracting to hit a number produces the failure mode above.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Under a long function that mixes four concerns, every one of those four kinds of change costs a read of the whole thing plus a test run that exercises all four, and two of them cannot be made in parallel by different people without a conflict.
- Under a split by responsibility — which typically produces four units of forty to sixty lines each, not fifteen of ten — each change touches one unit and one test file, and the concurrent-edit conflict disappears.
- Under a split by line count, the next change costs roughly what it did before plus the navigation. That is the specific claim worth internalising: the shredding move does not reduce the cost of the next change, which is the only thing it was supposed to buy.
- Judgement-based standards produce inconsistent review. Two reviewers applying "does it mix responsibilities" will disagree in a way that two reviewers applying "is it under fifty lines" will not, and that inconsistency has a real cost in team friction.
- Refusing a numeric rule means the team needs a shared idea of responsibility, which takes longer to build than a lint config and is not transferable to a new hire in one sentence.
- Leaving a long function alone forgoes the one thing extraction reliably buys even when done badly: named intermediate concepts that show up in stack traces and profiles.
What can go wrong
- The function is split and the shared locals become instance fields, so the class is now a function with the variables promoted to a wider scope and a longer lifetime (Hidden Global State).
- The extraction is done without tests on a function nobody fully understands, and a behaviour changes silently (Refactoring Without Tests).
- The line limit is enforced by a linter, so the codebase satisfies it everywhere and the same changes still take the same time — the number improved and nothing else did.
- The opposite failure, which is real: "long is fine" becomes the team position and the 400-line handler grows to 900, because nobody has a threshold at which they must at least look.
- A long function depends on everything it touches, all visible in one place. That is genuinely its advantage: the dependency list is the body, and nothing is hidden behind a call.
- Extracted helpers that share state through fields create a dependency between siblings that no signature records, which is strictly worse than the local variables it replaced (Temporal Coupling).
- Extracted units with real names and explicit parameters create declared dependencies you can see, substitute and test. That is the version worth paying for.
- "Length does not matter." It matters as evidence. A 500-line function is much more likely to mix concerns than a 50-line one; the point is that you check rather than that you ignore it.
- "So I should never extract." Extraction is the most useful refactoring there is when it names a real concept. The objection is only to extraction whose justification is arithmetic (Extract Function).
- "Small functions are always harder to follow." They are harder to follow when they are single-use fragments of a sequence, and easier when they are named domain operations with meaningful boundaries. Both exist and the difference is not size.
- "The book says ten lines, so the book is wrong." The book is describing a discipline that works well in the codebases and languages its author works in, and teams who apply it consistently do get real benefits. The claim being rejected here is only that the number is the criterion.
- god-object
- divergent-change
Testing it, and how it ages
- Before restructuring anything long that you do not fully understand, pin the current behaviour — including the parts that look like bugs, because some of them are relied on (Characterization Tests).
- A long function that can only be tested through five layers of setup is reporting mixed responsibilities. A long function with a dozen focused tests calling it directly is reporting that it is fine.
- Test the units you extract at their own boundary, and delete the tests that only existed to reach the extracted code through the parent (What a Unit Is).
- Long functions rarely start long. They accrete a case at a time, and each addition is locally reasonable — which is why "how did this happen" has a boring answer and why review is the only place to catch it.
- The useful threshold is not a length but a rate: a function that has been edited by three different teams this quarter is mixing concerns whatever its size, and change history tells you that faster than reading does (Divergent Change).
- A function that has stayed long and untouched for four years is not a problem to solve. It is code that works and nobody needs to change, which is the cheapest kind there is.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- CONTESTEDThe strongest form of the opposing view, stated fairly: a function should do one thing at one level of abstraction, and in practice that is nearly always under twenty lines, so the length limit is a reliable proxy for a property that is otherwise unenforceable. Its advocates argue that reading a well-named call is strictly cheaper than reading a body, that small units make responsibilities and testability visible, and — most persuasively — that teams without a numeric threshold have no Schelling point and reliably end up with thousand-line functions, because every individual addition is small. Teams that have run this discipline for years report code they can navigate by name alone. The counter-evidence is codebases where the rule was satisfied by shredding into single-use fragments sharing mutable fields, which is strictly worse than what it replaced. Both bodies of experience are real; the disagreement is about which failure your team is actually prone to.
- PARADIGM-SPECIFICIn a functional style, extraction is nearly free because there is no shared mutable state to leave behind, so the cost side of the argument is much smaller and short functions are close to unambiguously good. In imperative code with many live locals, extraction turns those locals into parameters or fields, which is exactly where the objection bites — the same advice is cheap in one idiom and expensive in the other.
- LANGUAGE-SPECIFICLanguages with cheap local functions, closures and destructuring make it easy to name a chunk without widening any scope; languages where the only unit is a top-level function or a public method make every extraction a decision about visibility and API surface, which raises its cost enough to change the answer.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the practical test for whether a split helped is whether the resulting units can be tested independently, and that criterion resolves most long-function arguments faster than counting anything.
- — Programming Languages & Runtime Internals — inlining means the runtime cost of extraction is usually zero in compiled languages and occasionally not in dynamic ones, so "small functions are slower" is a claim that needs measuring in your runtime rather than assuming.