The Single Responsibility Principle for Functions
I once spent three hours debugging a function called processOrder. It was 400 lines long. It validated the order, calculated shipping, applied discounts, charged the payment, updated inventory, sent confirmation emails, and logged analytics.
The bug was in line 287. Finding it required understanding all 400 lines.
This is what happens when functions do too much.
The Core Principle
A function should do one thing. It should do it well. It should do it only.
This is Robert C. Martin's formulation, and it's deceptively simple. The challenge is defining "one thing."
Here's a useful definition: a function does one thing if you can describe it with a single sentence that doesn't use "and."
- "Calculate the total price" ✓
- "Calculate the total price and apply discounts" ✗
- "Send a confirmation email" ✓
- "Send a confirmation email and update the order status" ✗
One Level of Abstraction
Another way to think about it: a function should operate at one level of abstraction.
Here's a function mixing levels:
async function createUser(userData) {
// High level: orchestration
const validatedData = validateUserData(userData);
// Low level: implementation detail
if (!validatedData.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
throw new Error('Invalid email format');
}
// High level again
const user = await saveUser(validatedData);
await sendWelcomeEmail(user);
// Low level again
console.log(`User ${user.id} created at ${new Date().toISOString()}`);
return user;
}
The function jumps between orchestrating operations and implementing details. This makes it hard to understand the flow.
Better:
async function createUser(userData) {
const validatedData = validateUserData(userData);
const user = await saveUser(validatedData);
await sendWelcomeEmail(user);
logUserCreation(user);
return user;
}
Now the function tells a clear story at one level. Details are pushed into sub-functions.
Signs Your Function Does Too Much
1. It's Hard to Name
If you can't name a function clearly, it probably does too much.
handleUserStuff— what stuff?processAndValidateAndSave— three things!doEverything— literally too much
2. It Has Many Parameters
Functions doing one thing rarely need many inputs.
// This function does too much
function processOrder(order, user, discount, shippingMethod,
paymentInfo, sendEmail, logAnalytics) { }
// Split it
function calculateOrderTotal(order, discount) { }
function chargePayment(order, paymentInfo) { }
function fulfillOrder(order, shippingMethod) { }
function notifyCustomer(user, order) { }
3. It Has Multiple Sections
If you can draw horizontal lines through a function to separate "phases," each phase is a separate function.
function handleCheckout(cart, user) {
// Phase 1: Validation
if (!cart.items.length) throw new Error('Empty cart');
if (!user.paymentMethod) throw new Error('No payment');
// ----- could be validateCheckout() -----
// Phase 2: Calculation
const subtotal = cart.items.reduce((sum, i) => sum + i.price, 0);
const tax = subtotal * TAX_RATE;
const total = subtotal + tax;
// ----- could be calculateTotal() -----
// Phase 3: Processing
chargePayment(user.paymentMethod, total);
updateInventory(cart.items);
sendConfirmation(user.email);
// ----- could be processPayment(), updateInventory(), notifyUser() -----
}
4. It's Long
There's no magic number, but:
- 5-15 lines: Probably right
- 15-30 lines: Review carefully
- 30+ lines: Almost certainly too much
Code Complete found that routines under 200 lines had the fewest bugs per line. But shorter is generally better for comprehension.
The "And" Test in Practice
Describe your function. If it contains "and," extract.
| Description | Assessment |
|---|---|
| "Parse the JSON input" | ✓ One thing |
| "Parse the JSON and validate the email" | ✗ Extract validation |
| "Calculate the order total" | ✓ One thing |
| "Calculate the order total and apply discounts and add tax" | ✗ Probably too much |
| "Send a welcome email" | ✓ One thing |
| "Send a welcome email and update the user's last contact date" | ✗ Extract the update |
Refactoring Example
Before:
async function handleUserRegistration(input) {
// Parse
const data = JSON.parse(input);
// Validate
if (!data.email?.includes('@')) {
throw new Error('Invalid email');
}
if (!data.password || data.password.length < 8) {
throw new Error('Password too short');
}
// Transform
const user = {
...data,
passwordHash: await hashPassword(data.password),
createdAt: Date.now(),
status: 'pending'
};
// Persist
await db.users.insert(user);
// Notify
await sendWelcomeEmail(user.email);
await logAnalytics('user_registered', { userId: user.id });
return user.id;
}
After:
async function handleUserRegistration(input) {
const data = parseRegistrationInput(input);
validateRegistrationData(data);
const user = await createAndSaveUser(data);
await notifyUserCreated(user);
return user.id;
}
function parseRegistrationInput(input) {
return JSON.parse(input);
}
function validateRegistrationData(data) {
validateEmail(data.email);
validatePassword(data.password);
}
function validateEmail(email) {
if (!email?.includes('@')) {
throw new Error('Invalid email');
}
}
function validatePassword(password) {
if (!password || password.length < 8) {
throw new Error('Password too short');
}
}
async function createAndSaveUser(data) {
const user = {
...data,
passwordHash: await hashPassword(data.password),
createdAt: Date.now(),
status: 'pending'
};
await db.users.insert(user);
return user;
}
async function notifyUserCreated(user) {
await sendWelcomeEmail(user.email);
await logAnalytics('user_registered', { userId: user.id });
}
More functions, but each is:
- Easy to name
- Easy to test
- Easy to modify
- Easy to understand
The Objection: "More Functions = More Overhead"
I hear this a lot. "Won't all these small functions make the code harder to navigate?"
No. They make it easier.
When you read the top-level function, you see the story without implementation details. You only dive into sub-functions when you need to.
This is like reading a book's table of contents vs. reading every page. The table of contents gives you structure; the pages give you details when you need them.
Key insight: A function should do one thing. Use the "and" test: if your description contains "and," extract. Operate at one level of abstraction. When a function is hard to name, it's doing too much.