NamingGENERALPARADIGM-SPECIFICCONTESTED

Function Design

A function is judged by what it needs, what it returns, what it changes, how many reasons it has to change, and whether its name is true. Length is not on the list.

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.

The question

What makes a function well designed, if not how long it is?

The requirement

A team wants a shared standard for "is this function good enough to merge", because review comments currently oscillate between "split this" and "this is over-abstracted" depending on who is reviewing.

The obvious build

A function should be small — under twenty lines, ideally under ten — and do one thing. Extract until that is true, and the design follows.

Why it breaks

"One thing" is not defined at any particular altitude. handleRequest does one thing; so does incrementCounter; the phrase gives a reviewer no way to adjudicate between two people who both believe they are right.

How it breaks as requirements change
  • "One thing" is not defined at any particular altitude. handleRequest does one thing; so does incrementCounter; the phrase gives a reviewer no way to adjudicate between two people who both believe they are right.
  • A line-count rule is satisfied by moving lines, not by fixing responsibilities. A two-hundred-line function split into fifteen twenty-line functions that all mutate the same five fields is the same function with worse ergonomics and a call graph to hold in your head.
  • It says nothing about the things that actually make a function hard to use: hidden inputs, hidden effects, partial functions that throw for ordinary inputs, and error behaviour a caller cannot see.
  • It is measurable, which is why it wins arguments against the criteria that matter, and why teams end up with a codebase full of small functions that are still expensive to change (Long Functions).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The standard has to be applicable in a five-minute review, or it will be replaced by a lint rule that counts lines — which is what happens whenever a design standard is too slow to apply.
  • The codebase mixes styles: some modules are functional and pure, others are classes with state, and a single rule that only works for one of them will be ignored in the other.
  • Some functions are genuinely constrained by a framework signature and cannot take different inputs, however much you would like them to.
Invariants
  • Everything a function needs in order to produce its result is visible in its signature, or the function cannot be reasoned about locally (Local Reasoning).
  • The name is true: what it says it does is what it does, and what it does not say it does, it does not do.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The signature owns the contract: what is needed, what comes back, what can fail. Anything true of the function that is not in the signature is a thing the caller has to learn some other way.
  • The body owns one coherent job at one level of abstraction — not "one thing", but "one reason to change" (Single Responsibility, Carefully).
  • The caller owns the decisions the function should not be making for it: where the clock comes from, where the data is written, what to do about a failure it cannot interpret.
Boundaries
  • The function boundary is where local reasoning either works or fails. If understanding it requires knowing what was set up elsewhere, the boundary is drawn in the wrong place (Temporal Coupling).
  • Pure computation and effects belong on different sides of a boundary, because one is trivially testable and the other needs a world (Functional Core, Imperative Shell).
  • A function that a framework calls has its signature dictated by the framework. Keep that function thin and put your design in the thing it delegates to (What a Framework Charges).

What a function is, as a unit with reasons to change

The most useful thing you can do to a suspicious function is not to count its lines but to write down what it knows, what it does, what it depends on, and every distinct thing that would make you edit it. The last list is the finding.

Here is a real example of the shape: a checkout handler that grew one requirement at a time, each of which was individually reasonable.

responsibilitiessubmitOrder(cart, userId, options)A function with four owners
Knows
  • how a line total and a tax amount are computed
  • which payment provider is configured
  • the shape of the confirmation email
  • which columns the orders table has
Does
  • validates the cart
  • prices it
  • charges the card
  • writes the order row
  • sends a confirmation
  • emits an analytics event
Depends on
  • TaxTable
  • StripeClient
  • OrdersRepository
  • EmailTemplates
  • AnalyticsSink
  • the system clock, read directly
Changes when — 6 distinct reasons
  • a tax rule changes
  • a payment provider is added or swapped
  • the email copy changes
  • the orders schema changes
  • the analytics event shape changes
  • a new validation rule arrives

Six reasons to change and six teams that can cause one. It does not matter whether this is forty lines or four hundred: it is the intersection of six concerns, so every one of those six changes is a change to a function that also charges cards. The finding is the length of changesWhen, and the fix is to give each of those reasons somewhere else to land (Designing by Responsibility).

Inputs you cannot see are the expensive ones

The single most reliable predictor of a function being painful is that its result depends on something not in its signature. The clock, an environment variable, a mutable singleton, or the assumption that some other function ran first.

This is worth separating from length because the two are unrelated. A four-line function with a hidden clock is harder to test than a hundred-line pure one, and no line-count rule will ever say so.

The same logic, with and without hidden inputs
1// Hidden inputs: the clock, and a config singleton.
2// Untestable without freezing time and mutating global config.
3function isEligibleForRefund(order: Order): boolean {
4 const days = (Date.now() - order.paidAt) / 86_400_000
5 return days <= Config.current().refundWindowDays
6}
7
8// Every input in the signature. Testable by calling it.
9function isEligibleForRefund(
10 order: Order, now: Date, windowDays: number,
11): boolean {
12 const days = (now.getTime() - order.paidAt) / 86_400_000
13 return days <= windowDays
14}
15
16// The caller now owns "what time is it" and "what is configured",
17// which are decisions it was always making — just invisibly.

The second version is one line longer and a different kind of thing: a total function whose output is determined entirely by its arguments. That property, not its size, is what makes it cheap to test and safe to call from anywhere (A Deterministic Core).

What actually goes wrong, and what it is telling you

Most function-level pain shows up first as a symptom in someone's workflow rather than as an opinion in review. These are the recurring ones and what each is really reporting.

Symptoms and their design causes
TriggerSymptomCauseResponse
A second caller needs the same calculation without the emailHalf the body is copy-pasted into a new functionComputation and effect are in the same unit, so the computation cannot be reused aloneSplit the pure calculation out and let both callers use it; the effect stays where it was (Functional Core, Imperative Shell)
A test needs a running database to check a rounding ruleThe unit test suite takes minutes and is flakyA domain rule lives inside a function that also persistsMove the rule to a unit with no I/O; test persistence separately at its own boundary (What a Unit Is)
The function returns null on three different failuresCallers write if (!x) return and lose the reasonFailure modes are collapsed into one absent valueModel the failures as distinct cases the caller can act on (An Error Taxonomy That Survives Contact)
Calling it twice produces a different result with the same argumentsA retry duplicates a chargeA hidden input, or an effect the name does not mentionMake the input explicit and the effect idempotent, and say both in the name (Idempotency by Design)
Nobody can name the function without "and" in the namevalidateAndSaveAndNotifyThe unit is the intersection of several concernsThe naming difficulty is the finding — split along the "and" (Naming)

How to build it

Most important first.

  • Make the inputs total and visible. If it reads a clock, a global, an environment variable or a singleton, those are inputs that lie outside the signature and they are why it is hard to test (Time as a Dependency).
  • Return one thing, and return it rather than mutating an argument to communicate. An out-parameter is an effect the name usually does not mention.
  • Make failure part of the signature — a Result, a checked exception, a documented throw — so the caller knows failure exists without reading the body (Error Modeling).
  • Keep effects at one level. A function that computes, then writes, then sends, has three reasons to change and cannot be tested without two of them (Side Effects).
  • Check cohesion by asking what would make you edit this: if the answers include a pricing rule, an email template and a database schema, it is three functions regardless of how many lines it has (Cohesion).
  • Then, and only then, look at size. Length is a prompt to ask the questions above, never a finding on its own (Long Functions).

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.

Cost of the next change
  • Under a function with visible inputs and one job, the next change is local: edit the body, run one fast test, and nothing else can be affected because nothing else is reachable from it.
  • Under a function with hidden inputs, the next change costs a search for everything that sets that hidden state, plus a test environment that reproduces it — which is why a five-line change to such a function is honestly estimated in days.
  • The change that stays expensive either way is a change to the signature itself, because that is a change to every caller. Which is the argument for getting the signature right early and for keeping the number of callers of a volatile signature small (Stable Boundaries).
What the recommended approach costs
  • Explicit inputs make signatures longer and push wiring outward, which some readers experience as ceremony — and in a small script, they are right.
  • Separating computation from effect means two units where there was one, plus an intermediate type to carry the result between them. That is a real cost that pays only if the computation is worth testing alone.
  • A judgement-based standard is slower to apply than a line limit and produces less consistent review comments. The counter is that the line limit is consistently wrong.

What can go wrong

Failure modes
  • The function is split to satisfy a rule, the pieces share mutable state through fields, and the shared state is now invisible where it used to be at least local (Shared-State Coupling).
  • Dependencies are made explicit by adding parameters until the signature is unreadable, and callers start passing values they do not care about through three layers (Long Parameter List).
  • The pure core is extracted and the shell keeps a copy of a rule "for validation", so the rule now lives in two places (Duplicate Knowledge).
  • Every function returns Result, including ones that cannot fail, and the codebase acquires unwrapping noise with no corresponding safety (Result Types).
Dependencies, and their direction
  • Every hidden input is an undeclared dependency: on a clock, on process state, on whatever ran first. Those are the dependencies that make a function untestable and their absence from the signature is the whole problem (Hidden Global State).
  • Declared dependencies point from the function toward abstractions it names. Hidden ones point at the entire process, which is why they cannot be substituted (Dependency Direction).
  • A function that takes ten parameters has ten dependencies and is telling you that it is really a process with state, or that several of those parameters belong together (Introduce Parameter Object).
Misreads
  • "So functions can be any length." Length is not a rule, but it is evidence. A long function is worth reading with the questions above in hand — the point is that the questions decide, not the count (Long Functions).
  • "Pass everything in as a parameter." Past a certain point that is a long parameter list, which is its own smell, and the answer is usually a cohesive object rather than more parameters (Introduce Parameter Object).
  • "Pure functions everywhere." A program that changes nothing does nothing. The goal is that effects live at a known boundary, not that they disappear (Effect Boundaries).
  • "This is what a linter is for." A linter can check parameter count and nesting depth, both of which are useful prompts. It cannot check whether the name is true, which is the criterion doing the most work here.
Smells this explains
  • long-parameter-list
  • feature-envy

Testing it, and how it ages

What to test, and at which boundary
  • A pure function with visible inputs is tested by calling it. If a test needs a container, a clock or a fixture database to test a calculation, that is a design finding rather than a testing problem (Testing as Design Feedback).
  • Test the contract in the signature: the total behaviour, the boundary values, and every failure the type says is possible.
  • Do not test private helpers directly. If a helper needs its own test, it is telling you it wants to be a unit with its own name and boundary (What a Unit Is).
How this design ages
  • Functions grow at the point where a requirement lands. That is normal; the question is whether the growth added a reason to change or just a case to an existing one.
  • A function acquiring its third hidden dependency is usually the moment it should become a small object or a module with explicit collaborators — not because objects are better, but because the dependencies deserve names.
  • Signatures that many callers depend on ossify. Expect to add a new function beside the old rather than change it, and to carry both for a while (Deprecation).

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.

  • GENERALVisible inputs, a truthful name and one reason to change are properties of a callable unit in any paradigm; a Haskell function, a Java method and a Bash function are all judged by them, and only the mechanisms for achieving them differ.
  • PARADIGM-SPECIFICIn OO code a method's receiver is an implicit input that is idiomatic and expected, so "all inputs in the signature" means "all inputs in the signature or the object's own declared state". In a functional codebase the same phrase means literally all of them, which is why functional reviewers and OO reviewers can both apply this standard honestly and disagree.
  • CONTESTEDThe strongest opposing position is that a hard, mechanical rule beats a judgement call in practice: reviewers apply "under twenty lines" consistently and "one reason to change" inconsistently, and a codebase of uniformly small functions is at least predictable. Teams that have run a strict limit for years report that it forces the naming conversation the criteria here only invite. The counter-evidence is codebases where the rule was satisfied by shredding rather than by decomposition, and both sets of experience are real.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

API Designerror-taxonomy
Domains that do not exist yet
  • Testing & Reliability Engineering — "can I test this without a world" is the fastest available proxy for whether a function's dependencies are declared, and this domain treats that difficulty as design feedback rather than as a testing chore.