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
calculateLateFeewith 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:
| Heuristic | Guideline |
|---|---|
| Lines of code | Single-screen (20–30 lines) as a target; red flag at 50+ |
| Indent levels | Max 2–3 levels; deep nesting signals a need to extract |
| Abstraction levels | One per function |
| Responsibilities | One — if you need “and” to describe it, split it |
| Parameters | Prefer 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.
Related practices
Part of the PushBackLog Best Practices Library. Suggest improvements →