The Quest Begins (The "Why")
I still remember the first time I opened a pull request that made my eyes glaze over. The title was simple: “Add user signup flow”. The diff, however, was a wall of code — a single function called handleSignup that stretched over 180 lines. Inside that beast lived validation rules, password hashing, database writes, email queuing, analytics logging, and even a little bit of UI‑specific formatting.
Every reviewer left a comment like “Can we break this up?” or “What happens if the email fails?”. I felt like I was trying to read a novel written in a single, never‑ending paragraph. The code worked, sure, but debugging a failure felt like defusing a bomb with a blindfold on.
That experience stuck with me. I realized that if I kept writing functions that tried to do everything, I’d spend more time untangling spaghetti than building features. I needed a quest‑worthy rule that would change the way I wrote code from the ground up.
The Revelation (The Insight)
The treasure I uncovered wasn’t a fancy framework or a new language feature — it was a mindset shift rooted in the Single Responsibility Principle, but applied at the function level: each function should do one thing, and do it well.
When a function has a single, clear purpose, three beautiful things happen:
- Readability skyrockets – you can glance at the name and instantly know what it does.
- Testing becomes trivial – you can isolate the behavior with a handful of unit tests instead of wrestling with mocks for half a dozen side‑effects.
- Future changes are safer – tweaking one responsibility doesn’t accidentally break another.
It sounded simple, but the real magic appeared when I started applying it religiously. Code reviews turned from dreaded marathons into quick, friendly walks. Bugs became easier to spot, and I found myself actually looking forward to refactoring.
Wielding the Power (Code & Examples)
Let’s look at a concrete before‑and‑after that shows why this practice matters. Imagine we’re building a user signup endpoint.
The Struggle (Before)
function handleSignup(userData) {
// 1️⃣ Validate input
if (!userData.email || !userData.password) {
throw new Error('Email and password required');
}
if (!/^[\w.-]+@[\w.-]+\.\w+$/.test(userData.email)) {
throw new Error('Invalid email format');
}
if (userData.password.length < 8) {
throw new Error('Password too short');
}
// 2️⃣ Hash password
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync(userData.password, salt);
// 3️⃣ Persist user
const userId = db.insert('users', {
email: userData.email,
passwordHash: hash,
createdAt: new Date(),
});
// 4️⃣ Send welcome email
emailService.send({
to: userData.email,
subject: 'Welcome!',
body: `Hi ${userData.email}, thanks for joining.`,
});
// 5️⃣ Log analytics
analytics.track('signup_complete', { userId });
// 6️⃣ Return something useful
return { userId };
}
What’s wrong?
- The function mixes validation, hashing, persistence, side‑effects (email, analytics), and a return value.
- If the email service is down, the user record is already saved — leaving the system in an inconsistent state.
- Writing a unit test means mocking the DB, the email service, and the analytics client all at once.
- A future developer adding a new step (e.g., forcing password reset) has to wade through 30 lines of unrelated code just to find the right spot.
The Victory (After)
// 1️⃣ Pure validation – throws if invalid
function validateSignupInput(data) {
if (!data.email || !data.password) {
throw new Error('Email and password required');
}
if (!/^[\w.-]+@[\w.-]+\.\w+$/.test(data.email)) {
throw new Error('Invalid email format');
}
if (data.password.length < 8) {
throw new Error('Password too short');
}
}
// 2️⃣ Pure hashing – no side effects
function hashPassword(plain) {
const salt = bcrypt.genSaltSync(10);
return bcrypt.hashSync(plain, salt);
}
// 3️⃣ Pure data access – returns the new id
function createUserRecord(email, passwordHash) {
return db.insert('users', {
email,
passwordHash,
createdAt: new Date(),
});
}
// 4️⃣ Side‑effect isolated – easy to mock or skip
function sendWelcomeEmail(email) {
emailService.send({
to: email,
subject: 'Welcome!',
body: `Hi ${email}, thanks for joining.`,
});
}
// 5️⃣ Analytics fire‑and‑forget
function logSignup(userId) {
analytics.track('signup_complete', { userId });
}
// Orchestrator – reads like a story
function handleSignup(userData) {
validateSignupInput(userData);
const passwordHash = hashPassword(userData.password);
const userId = createUserRecord(userData.email, passwordHash);
sendWelcomeEmail(userData.email); // fire‑and‑forget; errors logged elsewhere
logSignup(userId);
return { userId };
}
Why this feels like a level‑up:
- Each helper does exactly one thing and can be understood in isolation.
- The orchestrator
handleSignupnow reads like a recipe: validate → hash → store → notify → log. - If the email service fails, we can catch that error separately without rolling back the DB (or we can decide to make the whole operation transactional — the point is we see the choice).
- Unit tests are a breeze: test
validateSignupInputwith good/bad data, testhashPasswordwith known inputs, mockdb.insertforcreateUserRecord, etc.
When I first split that monster function, my code review comments dropped from “nested if‑else hell” to “looks solid, ship it!”. The confidence boost was real — my teammates started asking me for advice on refactoring, and I felt like I’d unlocked a new skill tree.
Why This New Power Matters
Adopting the “one thing per function” rule changed more than just my code — it reshaped how I think about problems.
- Speed: I spend less time deciphering what a function does and more time delivering value.
- Quality: Bugs surface earlier because each piece is simple enough to test exhaustively.
- Collaboration: New teammates can jump in, read a helper, and trust that it won’t surprise them elsewhere.
- Joy: Honestly, there’s a satisfying click when you extract a messy block into a neatly named function — it feels like leveling up in a game where the XP is clean code.
If you’ve ever stared at a function and wondered, “Why does this also send an email?”, you’ve felt the pain of mixed responsibilities. Giving each job its own stage eliminates that guesswork and makes your codebase far more maintainable.
Your Turn
Here’s a challenge that’s cheap, fun, and instantly rewarding: pick one function in your current codebase that makes you sigh when you open it. Set a timer for 15 minutes and break it down into smaller, single‑purpose helpers. Run your existing tests to make sure nothing broke, then watch how much easier it is to reason about that piece of code.
When you’re done, drop a comment below with the before/after snippet (or just a short description) — let’s celebrate those small victories together.
Happy refactoring, and may your functions always stay focused, readable, and a joy to review! 🚀
Top comments (0)