PushBackLog

Small Functions

Advisory enforcement Complete by PushBackLog team
Topic: quality Topic: supportability Skillset: any Technology: generic Stage: execution Stage: review

Small Functions

Status: Complete
Category: Clean Code
Default enforcement: Advisory
Author: PushBackLog team


Tags

  • Topic: quality, supportability
  • Skillset: any
  • Technology: generic
  • Stage: execution, review

Summary

Functions should do one thing, do it well, and do it only. A function that fits on a single screen without scrolling, has a descriptive name, and has no more than one level of abstraction is almost always better than one that doesn’t.


Rationale

Understanding, testing, and changing

The three activities that consume the most engineering time in an existing codebase are understanding code, testing code, and changing code. Small functions with clear names dramatically reduce the cost of all three:

  • Understanding: a function named calculateLateFee with 8 lines has a contract that can be grasped immediately. A function with 200 lines must be traversed from top to bottom to understand what it does.
  • Testing: a small, single-purpose function is easy to test: one input, one output, clear invariants. A large function with multiple responsibilities requires complex test setups and produces unclear failures.
  • Changing: a small function with a clear boundary is safe to modify. A large function is a minefield; touching one part can affect others.

The abstraction level principle

Robert C. Martin’s rule of thumb: each function should work at a single level of abstraction. A function that does high-level business logic (“validate the order and charge the customer”) should not also do low-level string manipulation or SQL queries. Mixing abstraction levels forces the reader to context-switch constantly.

Functions as documentation

A well-named small function is more reliable documentation than a comment block. It cannot go stale and it is guaranteed to be accurate — if the function’s implementation drifts, the name becomes wrong in an obvious, visible way that a stale comment does not.


Guidance

Size heuristics

These are guides, not hard rules:

HeuristicGuideline
Lines of codeSingle-screen (20–30 lines) as a target; red flag at 50+
Indent levelsMax 2–3 levels; deep nesting signals a need to extract
Abstraction levelsOne per function
ResponsibilitiesOne — if you need “and” to describe it, split it
ParametersPrefer 0–3; more than 3 usually means a data object is needed

Extract until it has a name

The standard refactoring technique is extract-until-named: keep splitting a function until every extracted piece can be given a clear, honest name. When you can’t name the piece, the split is wrong.

Command-query separation

Functions that return a value should not change state. Functions that change state should not return a value. This — command-query separation — makes functions dramatically easier to reason about and test.


Examples

Before: the god function

async function handleCheckout(cart: Cart, userId: string): Promise<string> {
  // validate user
  const user = await db.findUser(userId);
  if (!user) throw new Error('User not found');
  if (!user.emailVerified) throw new Error('Email must be verified');

  // validate cart
  if (cart.items.length === 0) throw new Error('Cart is empty');
  for (const item of cart.items) {
    const stock = await inventory.check(item.productId);
    if (stock < item.quantity) throw new Error(`${item.productId} out of stock`);
  }

  // calculate total
  let total = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  if (user.loyaltyPoints > 100) total *= 0.95;

  // charge
  const charge = await stripe.charge({ amount: total, customerId: user.stripeId });
  if (!charge.success) throw new Error('Payment failed');

  // create order
  const order = await db.createOrder({ userId, items: cart.items, total, chargeId: charge.id });

  // send confirmation email
  await email.send(user.email, 'Your order', `Order ${order.id} confirmed`);

  return order.id;
}

This function handles validation, inventory, discount calculation, payment, persistence, and email. Understanding, testing, or modifying any one concern requires reading the whole function.

After: composed small functions

async function handleCheckout(cart: Cart, userId: string): Promise<string> {
  const user = await validateUserForCheckout(userId);
  await validateCartInventory(cart);
  const total = calculateOrderTotal(cart, user);
  const chargeId = await chargeCustomer(user, total);
  const order = await createOrder(userId, cart, total, chargeId);
  await sendOrderConfirmation(user.email, order.id);
  return order.id;
}

Each extracted function can be tested, named, modified, or replaced independently. The top-level function reads like a specification.


Anti-patterns

1. The scroll-of-death function

A function spanning hundreds of lines, handling multiple concerns, that requires significant time to understand in full before any change can be made safely.

2. Boolean flag parameters

getUsers(includeInactive: boolean)

Boolean flags often signal that a function is doing two things. getActiveUsers() and getAllUsers() are clearer and independently testable.

3. Deep nesting as a sign of missing extractions

More than three levels of nesting almost always indicates logic that should be extracted. Use guard clauses to flatten or extract nested blocks into named functions.

4. Functions named with “and”

validateAndSave, fetchAndProcess, checkAndUpdate — the “and” is explicit evidence of multiple responsibilities. Split into two functions.

5. Output parameters

function enrich(user: User, result: EnrichedUser): void  // mutates result

Functions that mutate their parameters (aside from this) are hard to reason about. Return a new value instead.



Part of the PushBackLog Best Practices Library. Suggest improvements →