Steven's Knowledge

Functions

Cohesion, parameters, return values, and the right level of abstraction

Functions

The function is the smallest unit of behavior most programs reason about. The properties of a single function — what it depends on, what it changes, what it returns — propagate to every system built on top of it.

A Function Should Do One Thing

The most repeated rule in the literature, and the most often misapplied. "One thing" is defined relative to the function's level of abstraction:

A function does one thing if every step inside it is one level of abstraction below the function's name.

// Mixed levels — does several things
function processOrder(order) {
  if (!order.items.length) throw new Error('empty');
  const total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
  db.query('INSERT INTO orders ...');
  fetch('/email', { ... });
}

// Consistent level — does one thing
function processOrder(order) {
  validate(order);
  const total = computeTotal(order);
  persist(order, total);
  notifyCustomer(order);
}

The second version reads as a description of what happens. The reader can stop there or descend into any helper as needed.

Cohesion

Cohesion measures how strongly the elements of a function (or module) belong together. From strongest to weakest:

TypeDescriptionVerdict
FunctionalAll steps contribute to a single, well-defined taskTarget
SequentialOutput of one step feeds the nextAcceptable
CommunicationalSteps operate on the same dataAcceptable
TemporalSteps happen at the same time (e.g. init())Tolerable; split internals
ProceduralSteps follow a procedure but are otherwise unrelatedRefactor
LogicalA flag selects between alternativesSplit into separate functions
CoincidentalSteps grouped for no clear reasonRefactor immediately

Logical cohesion is the most common failure mode in growing code:

// Logical cohesion — flag controls behavior
function user(action, id, data) {
  if (action === 'create') { ... }
  else if (action === 'update') { ... }
  else if (action === 'delete') { ... }
}

// Functional cohesion — separate functions
function createUser(data) { ... }
function updateUser(id, data) { ... }
function deleteUser(id) { ... }

Parameters

Count

Empirically, functions with more than three or four parameters become hard to read and easy to call incorrectly. When the count grows:

  1. Group related parameters into an object that names the group:
    drawCircle(x, y, r, strokeColor, fillColor, strokeWidth)
    // becomes
    drawCircle({ center: { x, y }, radius: r, stroke: { color, width }, fill })
  2. Split the function if the parameters fall into independent clusters that are used at different times.

Order

Adopt a convention and apply it everywhere:

  • Inputs first, outputs last.
  • Required parameters first, optional last.
  • For (source, target) pairs, agree on a direction (copy(src, dst)) and keep it.

Avoid flag arguments

render(component, true)   // true means what?
render(component, false)

A boolean flag almost always indicates the function is doing two things. Split it:

renderWithDebugInfo(component)
renderProduction(component)

Use named arguments where the language allows

In languages with keyword arguments (Python, Kotlin, Swift) or destructuring (JavaScript, TypeScript), prefer named call sites over positional ones once you have more than two arguments. The cost at the call site is small; the readability gain is large.

Do not mutate inputs

A function that silently modifies its arguments is a hazard. Either return a new value, or make the mutation obvious in the name (appendTo(target, item) rather than add(item, target)).

Return Values

Be consistent about what "no result" means

Pick one convention per function and document it:

  • Return an empty collection rather than null for "no matches."
  • Use Optional / Maybe / nullable types to make absence explicit in the type signature.
  • Use the language's idiomatic error channel (exceptions, Result, error returns) for failures, not sentinel values.

Command-Query Separation

A function should either:

  • Query state and return information (no side effects), or
  • Command the system to change (no meaningful return value, or a small status).

Mixing the two makes the function impossible to call without thinking about both axes:

// Mixed — returning the deleted user is convenient until it bites you
const user = deleteUser(id);

// Separated
const user = getUser(id);
deleteUser(id);

Pragmatic exception: idiomatic patterns like pop(), compareAndSet(), or fluent builders break the rule deliberately. Break it deliberately, not by accident.

Length

There is no magic line count. The useful rule is:

A function is too long when you cannot read it in one sitting and remember what it does.

In practice, that tends to mean:

  • Most functions fit in well under a screen.
  • Public API entry points may be longer if the body is a sequence of named subroutines and reads top-to-bottom as documentation.
  • A function spanning hundreds of lines is almost always doing several things — split by extracting named subfunctions until each one fits the abstraction-level rule.

Length is a symptom, not the disease. The disease is mixed abstraction levels and weak cohesion.

Side Effects

Be explicit about what a function changes outside its own scope: parameters, instance state, global state, files, network, time. The fewer side effects per function, the easier the function is to test and reason about.

When a side effect is necessary, give it a name:

  • saveDraft() is honest about persisting.
  • formatDate() had better not write to a database.

Preconditions and Postconditions

Make the contract explicit at the boundary:

/**
 * Returns the user with the given ID.
 *
 * @param id  must be a non-empty string
 * @throws NotFoundError  if no user exists with that ID
 * @returns the user record; never null
 */
function getUser(id: string): User { ... }

Encode what the type system can: required vs optional, nullable vs non-nullable, narrow types over string. Use assertions for what the type system cannot, and documentation for what neither can.

Pre-Commit Checklist

  • Does the function name describe a single thing at one abstraction level?
  • Could a reader skim the body and understand the flow without descending into helpers?
  • Are there fewer than four parameters, or are they grouped into a parameter object?
  • Does it either query or command, but not both?
  • Are side effects either avoided or made obvious in the name?
  • Does the type signature carry as much of the contract as the language allows?

On this page