DEV Community

Timevolt
Timevolt

Posted on

The One Principle to Rule Them All: SRP and the Quest for Clean Code

The Quest Begins (The "Why")

I still remember the first time I opened a legacy codebase and saw a function called processUser(). At first glance it looked innocent—just a few lines, right? Wrong. Inside that 120‑line monster lived user validation, password hashing, email sending, audit logging, and a sprinkle of business rules for good measure. It was the kind of function that made you feel like you were facing the final boss in Dark Souls without a weapon: every change you made risked awakening a hidden attack pattern.

A teammate asked me to tweak the email template. I dove in, changed a string, ran the tests, and—boom—half the suite exploded because I’d accidentally tweaked the password‑hashing logic buried deep inside. I spent three hours tracing the call stack, feeling like a hobbit lost in Mordor, wondering why a simple UI change had turned into a full‑scale war. That moment was my “aha!”: the function wasn’t just doing too much; it had multiple reasons to change. If I could isolate those reasons, maybe I could finally stop the constant firefighting.

The Revelation (The Insight)

The treasure I uncovered that day was the Single Responsibility Principle (SRP)—the idea that a class, module, or function should have one, and only one, reason to change. In plain English: if you can think of two different stakeholders who might ask you to modify the same piece of code for different reasons, you’ve got a violation.

Why does this matter? Because every extra responsibility is a hidden coupling point. When you change one thing, you unintentionally risk breaking another. Tests become brittle, documentation lags, and onboarding new devs feels like handing them a map written in ancient runes. SRP gives us a clean separation: each piece of code owns a single concern, making it easier to reason about, test, and replace.

Think of it like the Fellowship splitting up to tackle different tasks—one guards the gate, another forges the sword, a third tends the fire. Each member has a clear job, and when the quest needs a change, you only need to talk to the relevant member, not the whole party.

Wielding the Power (Code & Examples)

The Trap: A God‑Function

Here’s a typical “before” snippet I’ve seen far too often (in JavaScript, but the idea translates anywhere):

function processUser(userData) {
  // 1️⃣ Validate input
  if (!userData.email || !userData.password) {
    throw new Error('Email and password required');
  }
  if (!/\S+@\S+\.\S+/.test(userData.email)) {
    throw new Error('Invalid email format');
  }

  // 2️⃣ Hash password (bcrypt)
  const salt = bcrypt.genSaltSync(10);
  const hash = bcrypt.hashSync(userData.password, salt);

  // 3️⃣ Save to DB
  const db = getDB();
  const result = db.collection('users').insertOne({
    email: userData.email,
    passwordHash: hash,
    createdAt: new Date(),
  });

  // 4️⃣ Send welcome email
  const emailPayload = {
    to: userData.email,
    subject: 'Welcome aboard!',
    text: `Hi ${userData.email}, thanks for signing up.`,
  };
  emailService.send(emailPayload);

  // 5️⃣ Log audit trail
  auditLog.info(`User created: ${userData.email}`);

  return result.insertedId;
}
Enter fullscreen mode Exit fullscreen mode

What’s wrong?

  • Validation could change because the product team wants stricter password rules.
  • Hashing might change if we switch from bcrypt to Argon2.
  • Persistence could change if we migrate to a different DB or add encryption.
  • Email might change due to a new template or provider.
  • Logging could change if we adopt a new observability stack.

One function, five reasons to change. Every tweak risks a ripple effect.

The Victory: SRP‑Driven Refactor

Let’s break each concern into its own tiny, focused helper.

// 1️⃣ Validation – only validates
function validateUser(data) {
  if (!data.email || !data.password) {
    throw new Error('Email and password required');
  }
  if (!/\S+@\S+\.\S+/.test(data.email)) {
    throw new Error('Invalid email format');
  }
}

// 2️⃣ Password hashing – only hashes
function hashPassword(plain) {
  const salt = bcrypt.genSaltSync(10);
  return bcrypt.hashSync(plain, salt);
}

// 3️⃣ Persistence – only saves
async function saveUser(email, passwordHash) {
  const db = getDB();
  const result = await db.collection('users').insertOne({
    email,
    passwordHash,
    createdAt: new Date(),
  });
  return result.insertedId;
}

// 4️⃣ Notification – only sends email
function sendWelcomeEmail(email) {
  const payload = {
    to: email,
    subject: 'Welcome aboard!',
    text: `Hi ${email}, thanks for signing up.`,
  };
  return emailService.send(payload);
}

// 5️⃣ Auditing – only logs
function logUserCreation(email) {
  auditLog.info(`User created: ${email}`);
}

// Orchestrator – thin, reads like a story
async function processUser(userData) {
  validateUser(userData);                 // 1️⃣
  const hash = hashPassword(userData.password); // 2️⃣
  const id   = await saveUser(userData.email, hash); // 3️⃣
  sendWelcomeEmail(userData.email);       // 4️⃣
  logUserCreation(userData.email);        // 5️⃣
  return id;
}
Enter fullscreen mode Exit fullscreen mode

What changed?

  • Each helper has a single reason to change.
  • The orchestrator (processUser) now reads like a high‑level recipe: validate → hash → save → email → log.
  • If the marketing team wants a new email template, I only touch sendWelcomeEmail.
  • If security decides to upgrade the hashing algorithm, I only edit hashPassword.
  • Unit tests become trivial: I can mock the DB, email service, and logger and test each piece in isolation.

Common Traps to Avoid

  1. “Just one more line” creep – It’s tempting to slip a tiny logging statement into a validation function because “it’s only one line.” Resist! That line adds a second reason to change.
  2. God objects masquerading as services – A class called UserService that handles validation, persistence, notifications, and reporting is still a god‑object in disguise. Split it into UserValidator, UserRepository, UserNotifier, etc.
  3. Over‑abstracting early – Don’t create a dozen interfaces before you know the responsibilities. Start with concrete functions, then extract when you see a clear single concern.

Why This New Power Matters

When you start writing code with SRP in mind, you’ll notice immediate benefits:

  • Readability – Your code tells a story. Future you (or a teammate) can glance at a function and know exactly what it does without diving into nested conditionals.
  • Testability – Small, focused units are trivial to unit test. You spend less time setting up mocks and more time asserting behavior.
  • Maintainability – Change impact is localized. A bug in the email module won’t corrupt user data, and a performance tweak in the hashing routine won’t break the login flow.
  • Confidence – You stop fearing every refactor. Knowing each piece has a single purpose gives you the courage to improve, experiment, and evolve the system.

It’s like gaining a new spell in your developer’s grimoire: “Separatio Unus”—the incantation that splits chaos into order.

Your Turn

Pick one function in your current project that feels like a Swiss‑army knife—validating, transforming, persisting, notifying, all at once. Spend fifteen minutes extracting its concerns into separate, single‑purpose helpers. Write a test for each. Notice how the orchestrator becomes a clear, readable pipeline.

What was the biggest “aha!” moment you felt when you finally saw the separation? Drop a comment below—I’d love to hear your war stories from the clean‑code trenches!

Happy coding, and may your commits always be clean! 🚀

Top comments (0)