DEV Community

Timevolt
Timevolt

Posted on

Refactoring Like a Jedi: Mastering the Single Responsibility Principle

The Quest Begins (The “Why”)

I still remember the first time I opened a legacy codebase and felt like I’d stepped into the Death Star’s trash compactor. The file was a single UserService.js that stretched over 800 lines. Inside, you’d find validation rules, password hashing, email‑sending logic, database calls, logging, and even a sprinkle of UI‑specific formatting.

I was tasked with fixing a tiny bug: users with a hyphen in their last name weren’t seeing their confirmation email. I dove in, added a console.log, stepped through the function, and … nothing. The email was being sent, but the template was being built with the wrong name because the formatting code lived three hundred lines away, tangled with the DB update. After two hours of scrolling, I finally spotted the offending line, fixed it, and pushed the change.

The next day, a QA engineer reported that password reset links were now broken for users who hadn’t logged in in over a year. Turns out my tiny tweak had inadvertently changed the order of operations in the validation block, which also affected the reset flow. I felt like I’d just swung a lightsaber and knocked over the whole Jedi Council.

That experience taught me a painful lesson: when a piece of code wears too many hats, changing one thing can unintentionally strangle another. I needed a guiding principle—a lightsaber that could cut through the chaos.

The Revelation (The Insight)

The principle that finally clicked for me is the Single Responsibility Principle (SRP): a class, function, or module should have only one reason to change. In other words, it should do one job and do it well.

When I first heard SRP, it sounded like academic jargon. But once I applied it, the fog lifted. Each piece of code became a clear, focused tool—easy to test, easy to reason about, and safe to modify. The same function that once tried to validate, persist, notify, and log now had a singular purpose, and the rest of the system could call it without worrying about hidden side effects.

Wielding the Power (Code & Examples)

The Struggle: A God‑Object in the Wild

Here’s a simplified version of that nightmare UserService I wrestled with:

class UserService {
  constructor(userRepo, mailer, logger) {
    this.userRepo = userRepo;
    this.mailer   = mailer;
    this.logger   = logger;
  }

  register(userData) {
    // 1️⃣ Validation (mixes business rules with UI concerns)
    if (!userData.email.match(/^.+@.+\..+$/)) {
      throw new Error('Invalid email');
    }
    if (userData.password.length < 8) {
      throw new Error('Password too weak');
    }

    // 2️⃣ Password hashing (crypto logic)
    const salt = crypto.randomBytes(16).toString('hex');
    const hash = crypto.pbkdf2Sync(
      userData.password,
      salt,
      1000,
      64,
      'sha512'
    ).toString('hex');

    // 3️⃣ Persistence (direct DB call)
    const user = this.userRepo.create({
      email: userData.email,
      passwordHash: hash,
      salt,
      createdAt: new Date()
    });

    // 4️⃣ Email sending (templating + transport)
    const welcomeHtml = `
      <h1>Welcome, ${userData.email.split('@')[0]}!</h1>
      <p>Thanks for joining our platform.</p>
    `;
    this.mailer.send({
      to: userData.email,
      subject: 'Welcome!',
      html: welcomeHtml
    });

    // 5️⃣ Logging (cross‑cutting concern)
    logger.info(`User registered: ${user.id}`);
    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode

What’s wrong here?

  • Validation is intertwined with UI‑specific email formatting.
  • Password hashing lives inside the service, making it hard to swap algorithms.
  • Persistence is a direct call to a repository, but the service also decides what data to store.
  • Email composition and sending are coupled to the registration flow.
  • Logging is sprinkled throughout, scattering a cross‑cutting concern.

If I needed to change the email template, I had to touch the same file that handled password hashing—risking a regression in security. If I wanted to unit‑test the validation logic, I had to mock the mailer, the repo, and the logger just to isolate a few lines. It was a maintenance nightmare.

The Victory: Splitting Responsibilities

Applying SRP, I broke the monolith into focused collaborators:

// 1️⃣ Validation – pure function, no side effects
function validateUser(data) {
  if (!data.email.match(/^.+@.+\..+$/)) {
    throw new Error('Invalid email');
  }
  if (data.password.length < 8) {
    throw new Error('Password too weak');
  }
}

// 2️⃣ Password hashing – utility module
function hashPassword(password) {
  const salt = crypto.randomBytes(16).toString('hex');
  const hash = crypto.pbkdf2Sync(
    password,
    salt,
    1000,
    64,
    'sha512'
  ).toString('hex');
  return { salt, hash };
}

// 3️⃣ Repository – thin data‑access layer (already SRP‑friendly)
class UserRepository {
  create(userProps) {
    // DB insert logic …
    return savedUser;
  }
}

// 4️⃣ Email service – encapsulates templating & transport
class EmailService {
  constructor(mailer, templateRenderer) {
    this.mailer = mailer;
    this.renderer = templateRenderer;
  }

  sendWelcome(email) {
    const html = this.renderer.render('welcome', { email });
    this.mailer.send({ to: email, subject: 'Welcome!', html });
  }
}

// 5️⃣ Logger – Aspect‑like helper (could be AOP, but keep it simple)
class Logger {
  info(message) {
    console.log(`[INFO] ${new Date().toISOString()} ${message}`);
  }
}

// 6️⃣ Orchestrator – now just coordinates the single‑responsibility pieces
class UserRegistrationService {
  constructor(
    validator,
    hasher,
    repo,
    emailSvc,
    logger
  ) {
    this.validator = validator;
    this.hasher    = hasher;
    this.repo      = repo;
    this.emailSvc  = emailSvc;
    this.logger    = logger;
  }

  register(data) {
    this.validator.validate(data);               // 1️⃣
    const { salt, hash } = this.hasher.hash(data.password); // 2️⃣
    const user = this.repo.create({              // 3️⃣
      email: data.email,
      passwordHash: hash,
      salt,
      createdAt: new Date()
    });
    this.emailSvc.sendWelcome(data.email);       // 4️⃣
    this.logger.info(`User registered: ${user.id}`); // 5️⃣
    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode

What changed?

  • Each piece now has one and only one reason to change.
  • The validator can be unit‑tested in isolation—no mailer, no DB.
  • Swapping the hashing algorithm is a one‑line change in hashPassword.
  • The email service can be reused for password resets, marketing newsletters, etc.
  • The orchestrator (UserRegistrationService) is now a clear, readable workflow that’s easy to follow and to modify without fearing hidden side effects.

When I ran the same test suite after this refactor, the build passed on the first try. Adding a new field to the user model? Just tweak the repository and the DTO—no need to dig through validation or email logic. The confidence boost was palpable; I felt like I’d finally mastered the Force instead of flailing with a blunt stick.

Why This New Power Matters

Adhering to SRP isn’t just about tidy code—it translates directly to shipping faster and sleeping better.

  • Testability: Small, focused units mean fewer mocks, faster tests, and quicker feedback loops.
  • Maintainability: When a bug appears, you know exactly which file to open. No more hunting through 800‑line beasts.
  • Reusability: A validator or email service can be dropped into other features without dragging along unrelated baggage.
  • Team Velocity: New teammates can grasp a single responsibility in minutes, not hours.
  • Safety: Changes are localized, reducing the risk of unintended side effects that slip into production.

In short, SRP turns your codebase from a tangled mess of Christmas lights into a set of neatly labeled, interchangeable LEGO bricks—each one strong on its own, and together capable of building anything you imagine.

Your Turn: Embark on Your Own Quest

Look at the code you wrote today. Find a function or class that does more than one obvious thing—maybe it validates input, talks to a database, and sends a notification all at once. Break it apart. Give each piece a single, clear job. Write a test for the new, smaller unit. Notice how the fog lifts.

What’s the first responsibility you’ll extract? Share your before/after snippets in the comments—I’d love to see your journey from lightsaber novice to Jedi master!

May the refactor be with you. 🚀

Top comments (0)