OOP Interview Prep
SOLID Principles

Single Responsibility Principle

One Class, One Reason to Change

LinkedIn Hook

Here is the question that trips up a lot of developers who have been writing classes for years:

"What is the Single Responsibility Principle?"

Most people say: "A class should do only one thing." That sounds right. But the interviewer's follow-up is where it gets hard: "How do you know when a class is doing too much? How would you spot the violation? Show me a before and after."

A rough definition is not enough. Interviewers want to see that you can recognize a God class when you read one, explain why it causes problems, and refactor it into focused, independent classes.

That is exactly what this lesson walks through.

Read the full lesson with code and refactoring example → [link]

#OOP #SOLID #JavaScript #SoftwareDesign #InterviewPrep


Single Responsibility Principle thumbnail


What You'll Learn

  • The precise definition of SRP and what "one reason to change" actually means in practice
  • How to detect SRP violations using the "and" test on class descriptions
  • A full before/after refactoring example showing a God class broken into focused, single-purpose classes
  • Why God classes form in the first place and why they hurt long-term maintenance
  • The three most common SRP-related interview traps

The Analogy That Makes SRP Click

Think about a restaurant kitchen.

A restaurant does not hire one person to take orders, cook the food, wash the dishes, handle the cash register, and manage the schedule. Each person has a defined role. The chef cooks. The server takes orders. The cashier handles payments. The manager handles the schedule.

Why does this matter? Because if you need to change how payments are processed, only the cashier's workflow changes. The chef keeps cooking. The server keeps serving. Nobody else is disrupted.

Now imagine one person doing all of those jobs. Every time the restaurant changes its payment system, its menu, its scheduling software, or its dishwashing process, that one person is affected. They are hard to replace. They are hard to train. When something breaks, you have no idea which "job" caused the problem.

A God class in code is exactly that overloaded employee.

SRP says: give each class one job, and one reason to change. That is it.

[INTERNAL-LINK: how SOLID principles connect to design patterns → Lesson 8.1: What are Design Patterns]


What Does "One Reason to Change" Actually Mean?

SRP is often explained as "a class should do only one thing." That definition is true but vague. The more precise version, from Robert C. Martin who formalized the principle, is: a class should have only one reason to change.

"Reason to change" means: one actor, one stakeholder, one department whose requirements drive that class. If a class serves the database team, the email team, and the security team all at once, then a requirement change from any of those three teams can break it. That is three reasons to change.

The practical test is simple. Try to describe the class in one sentence without using the word "and." If you find yourself saying "this class handles user registration and sends emails and saves to the database," you have identified at least three responsibilities. Each "and" is a potential SRP violation.

[UNIQUE INSIGHT]: The "one reason to change" framing is more useful than "one thing" in interviews because it maps directly to organizational reality. Classes that mix business logic with database code are fragile because two completely different teams, business analysts and DBAs, both have the power to force changes to the same file. That is the actual risk SRP is protecting against.


What Is a God Class?

A God class is a class that tries to know everything and do everything. It accumulates responsibilities over time, usually because adding a new method to an existing class feels faster than creating a new one. The class grows until it becomes the central hub of the application: 300, 500, 1000 lines, dozens of methods spanning unrelated concerns.

God classes cause several concrete problems.

First, they are hard to test. A class with six responsibilities requires mocking six different dependencies in every test. Second, they create merge conflicts. When two developers change the same massive file for unrelated reasons, Git cannot resolve it cleanly. Third, they make bugs harder to isolate. When the class fails, you do not know which responsibility broke. Fourth, they resist reuse. You cannot import just the email logic because it is tangled with the database logic inside the same object.

The God class anti-pattern is one of the most common sources of unmaintainable code in real production codebases.

[IMAGE: Diagram showing a bloated "God class" box with arrows pointing inward from Database, Email, Validation, Logging, and Reporting modules - search terms: "God class anti-pattern software design bloated class diagram"]


Before: A Classic God Class Violation

The following UserService class does user validation, password hashing, database storage, email sending, and activity logging. Try describing it without "and." You cannot.

// BEFORE — God class: UserService does everything
// Problem: 5 separate reasons to change, 5 separate stakeholders
// If the email provider changes, the database schema changes, or the
// validation rules change, this entire class is affected every time.

class UserService {
  constructor(db, emailClient, logger) {
    this.db = db;
    this.emailClient = emailClient;
    this.logger = logger;
  }

  // Responsibility 1: input validation (product/business team owns this)
  validateUserInput(userData) {
    if (!userData.email || !userData.email.includes("@")) {
      throw new Error("Invalid email address");
    }
    if (!userData.password || userData.password.length < 8) {
      throw new Error("Password must be at least 8 characters");
    }
    if (!userData.name || userData.name.trim().length === 0) {
      throw new Error("Name is required");
    }
    return true;
  }

  // Responsibility 2: password security (security team owns this)
  hashPassword(plainPassword) {
    // Simplified hash — in production use bcrypt or argon2
    const salt = Math.random().toString(36).substring(2);
    return `hashed_${salt}_${plainPassword}`;
  }

  // Responsibility 3: database persistence (DB/backend team owns this)
  saveUser(userData) {
    const hashedPassword = this.hashPassword(userData.password);
    const user = {
      id: Date.now(),
      name: userData.name,
      email: userData.email,
      password: hashedPassword,
      createdAt: new Date().toISOString(),
    };
    this.db.insert("users", user);
    return user;
  }

  // Responsibility 4: email communication (marketing/comms team owns this)
  sendWelcomeEmail(user) {
    const subject = "Welcome to our platform!";
    const body = `Hello ${user.name}, your account is ready. Email: ${user.email}`;
    this.emailClient.send({ to: user.email, subject, body });
  }

  // Responsibility 5: activity logging (devops/monitoring team owns this)
  logRegistration(user) {
    this.logger.info(`New user registered: id=${user.id} email=${user.email}`);
  }

  // Orchestrator method — calls all of the above
  // One change to any single responsibility forces retesting this whole class
  registerUser(userData) {
    this.validateUserInput(userData);
    const user = this.saveUser(userData);
    this.sendWelcomeEmail(user);
    this.logRegistration(user);
    return user;
  }
}

// The problem surfaces when requirements change:
// - Business team changes validation rules -> edit UserService
// - Security team changes hashing algorithm -> edit UserService
// - DBA changes table schema -> edit UserService
// - Marketing changes the welcome email copy -> edit UserService
// - DevOps changes the logging format -> edit UserService
// Five independent reasons to change. Five teams stepping on each other.

Single Responsibility Principle visual 1


After: SRP-Compliant Refactoring

The refactoring splits each responsibility into its own focused class. Each class now has exactly one reason to change. The orchestration still happens, but it lives in a thin coordinator that only wires the pieces together.

// AFTER — SRP applied: each class has one responsibility

// ---------------------------------------------------------------
// Class 1: UserValidator
// Responsibility: validate raw user input
// Only reason to change: business validation rules change
// ---------------------------------------------------------------
class UserValidator {
  validate(userData) {
    if (!userData.email || !userData.email.includes("@")) {
      throw new Error("Invalid email address");
    }
    if (!userData.password || userData.password.length < 8) {
      throw new Error("Password must be at least 8 characters");
    }
    if (!userData.name || userData.name.trim().length === 0) {
      throw new Error("Name is required");
    }
    return true;
  }
}

// ---------------------------------------------------------------
// Class 2: PasswordHasher
// Responsibility: securely hash passwords
// Only reason to change: security team changes the hashing algorithm
// ---------------------------------------------------------------
class PasswordHasher {
  hash(plainPassword) {
    // Simplified — use bcrypt or argon2 in production
    const salt = Math.random().toString(36).substring(2);
    return `hashed_${salt}_${plainPassword}`;
  }

  verify(plainPassword, hashedPassword) {
    // In production: bcrypt.compare(plainPassword, hashedPassword)
    return hashedPassword.includes(plainPassword);
  }
}

// ---------------------------------------------------------------
// Class 3: UserRepository
// Responsibility: persist and retrieve user records
// Only reason to change: database schema or storage layer changes
// ---------------------------------------------------------------
class UserRepository {
  constructor(db) {
    this.db = db;
  }

  save(userData, hashedPassword) {
    const user = {
      id: Date.now(),
      name: userData.name,
      email: userData.email,
      password: hashedPassword,
      createdAt: new Date().toISOString(),
    };
    this.db.insert("users", user);
    return user;
  }

  findByEmail(email) {
    return this.db.query("users", { email });
  }
}

// ---------------------------------------------------------------
// Class 4: WelcomeEmailSender
// Responsibility: send the welcome email after registration
// Only reason to change: email content or delivery provider changes
// ---------------------------------------------------------------
class WelcomeEmailSender {
  constructor(emailClient) {
    this.emailClient = emailClient;
  }

  send(user) {
    const subject = "Welcome to our platform!";
    const body = `Hello ${user.name}, your account is ready. Email: ${user.email}`;
    this.emailClient.send({ to: user.email, subject, body });
  }
}

// ---------------------------------------------------------------
// Class 5: RegistrationLogger
// Responsibility: log registration events for monitoring
// Only reason to change: logging format or destination changes
// ---------------------------------------------------------------
class RegistrationLogger {
  constructor(logger) {
    this.logger = logger;
  }

  logSuccess(user) {
    this.logger.info(`New user registered: id=${user.id} email=${user.email}`);
  }

  logFailure(email, error) {
    this.logger.error(`Registration failed for ${email}: ${error.message}`);
  }
}

// ---------------------------------------------------------------
// Coordinator: UserRegistrationService
// Responsibility: orchestrate the registration workflow
// Only reason to change: the registration steps or their order change
// ---------------------------------------------------------------
class UserRegistrationService {
  constructor(validator, hasher, repository, emailSender, registrationLogger) {
    this.validator = validator;
    this.hasher = hasher;
    this.repository = repository;
    this.emailSender = emailSender;
    this.registrationLogger = registrationLogger;
  }

  register(userData) {
    try {
      // Step 1: validate input (delegates to UserValidator)
      this.validator.validate(userData);

      // Step 2: hash password (delegates to PasswordHasher)
      const hashedPassword = this.hasher.hash(userData.password);

      // Step 3: persist user (delegates to UserRepository)
      const user = this.repository.save(userData, hashedPassword);

      // Step 4: send welcome email (delegates to WelcomeEmailSender)
      this.emailSender.send(user);

      // Step 5: log the event (delegates to RegistrationLogger)
      this.registrationLogger.logSuccess(user);

      return user;
    } catch (error) {
      this.registrationLogger.logFailure(userData.email, error);
      throw error;
    }
  }
}

// ---------------------------------------------------------------
// Usage — wiring dependencies together (typically done at app startup)
// ---------------------------------------------------------------
const db = { insert: (table, row) => console.log(`DB: saved to ${table}`, row) };
const emailClient = { send: (msg) => console.log(`EMAIL: sent to ${msg.to}`) };
const logger = {
  info: (msg) => console.log(`[INFO] ${msg}`),
  error: (msg) => console.log(`[ERROR] ${msg}`),
};

const registrationService = new UserRegistrationService(
  new UserValidator(),
  new PasswordHasher(),
  new UserRepository(db),
  new WelcomeEmailSender(emailClient),
  new RegistrationLogger(logger)
);

registrationService.register({
  name: "Alice",
  email: "alice@example.com",
  password: "securePass123",
});

// Output:
// DB: saved to users { id: ..., name: 'Alice', email: 'alice@example.com', ... }
// EMAIL: sent to alice@example.com
// [INFO] New user registered: id=... email=alice@example.com

Now each class can change independently. The security team can swap the hashing algorithm in PasswordHasher without touching anything else. Marketing can rewrite the welcome email in WelcomeEmailSender without opening a database file. DevOps can change the logging format in RegistrationLogger without affecting validation.

[INTERNAL-LINK: injecting dependencies through the constructor as shown above → Lesson 7.5: Dependency Inversion Principle]


Code Example 2 — Detecting Violations with the "And" Test

The "and" test is a fast heuristic you can use during a code review or an interview. Describe the class in one sentence. Every "and" you use reveals a potential SRP violation.

// DETECTION EXERCISE — apply the "and" test to each class description

// Class A: "This class validates a product and calculates its price
//           and formats it for display."
// "and" count: 2 — three responsibilities: validation, pricing, formatting
class ProductManager {
  validate(product) {
    if (!product.name) throw new Error("Name required");
    if (product.price < 0) throw new Error("Price cannot be negative");
  }

  calculateDiscountedPrice(product, discountPercent) {
    return product.price * (1 - discountPercent / 100);
  }

  formatForDisplay(product) {
    return `${product.name} - $${product.price.toFixed(2)}`;
  }
}

// VIOLATION CONFIRMED: three separate reasons to change
// Fix: split into ProductValidator, PricingCalculator, ProductFormatter

// ---------------------------------------------------------------

// Class B: "This class represents an order."
// "and" count: 0 — one responsibility: model an order
class Order {
  constructor(id, items, customerId) {
    this.id = id;
    this.items = items;
    this.customerId = customerId;
    this.status = "pending";
  }

  addItem(item) {
    this.items.push(item);
  }

  getTotal() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  updateStatus(newStatus) {
    const validStatuses = ["pending", "confirmed", "shipped", "delivered", "cancelled"];
    if (!validStatuses.includes(newStatus)) {
      throw new Error(`Invalid status: ${newStatus}`);
    }
    this.status = newStatus;
  }
}

// NO VIOLATION: Order only models order data and order-level behavior
// All methods relate directly to what an order IS and DOES

// ---------------------------------------------------------------

// Class C: "This class handles report generation and sends the
//           report by email."
// "and" count: 1 — two responsibilities: generation, delivery
class ReportService {
  generate(data) {
    return {
      title: "Sales Report",
      rows: data.map((d) => `${d.product}: ${d.units} units`),
      generatedAt: new Date().toISOString(),
    };
  }

  sendByEmail(report, recipient) {
    // sending logic mixed into the same class
    console.log(`Sending report "${report.title}" to ${recipient}`);
  }
}

// VIOLATION CONFIRMED: two reasons to change
// Fix: split into ReportGenerator and ReportEmailDelivery

// CORRECTED VERSION
class ReportGenerator {
  generate(data) {
    return {
      title: "Sales Report",
      rows: data.map((d) => `${d.product}: ${d.units} units`),
      generatedAt: new Date().toISOString(),
    };
  }
}

class ReportEmailDelivery {
  constructor(emailClient) {
    this.emailClient = emailClient;
  }

  send(report, recipient) {
    console.log(`Sending report "${report.title}" to ${recipient}`);
    // this.emailClient.send(...)
  }
}

[PERSONAL EXPERIENCE]: The "and" test is the fastest SRP check I use in code reviews. It catches violations in seconds, without needing to read the full implementation. When a class description requires two "ands," the class usually has 200 or more lines. At three "ands," it is typically approaching God class territory and is almost certainly untestable in isolation.


Code Example 3 — SRP Applied to a Real-World HTTP Handler

God classes appear frequently in backend route handlers. A handler that parses the request, validates input, runs business logic, queries the database, and formats the response is doing five jobs. SRP pushes each job into its own layer.

// BEFORE — route handler doing everything
// Seen in many Express.js tutorials, problematic in production code
function registerUserHandler(req, res) {
  // 1. Parse input from request
  const { name, email, password } = req.body;

  // 2. Validate directly in the handler
  if (!email || !email.includes("@")) {
    return res.status(400).json({ error: "Invalid email" });
  }
  if (!password || password.length < 8) {
    return res.status(400).json({ error: "Password too short" });
  }

  // 3. Hash password directly in the handler
  const hashedPassword = `hashed_${password}`;

  // 4. Database operation directly in the handler
  const user = {
    id: Date.now(),
    name,
    email,
    password: hashedPassword,
  };
  // db.insert(user) -- inline

  // 5. Response formatting directly in the handler
  res.status(201).json({ message: "User created", userId: user.id });
}

// ---------------------------------------------------------------

// AFTER — handler delegates to focused classes
// Each class can be tested, swapped, and changed independently

class UserValidator {
  validate({ name, email, password }) {
    if (!email || !email.includes("@")) throw new Error("Invalid email");
    if (!password || password.length < 8) throw new Error("Password too short");
    if (!name || !name.trim()) throw new Error("Name required");
  }
}

class PasswordHasher {
  hash(plain) {
    return `hashed_${plain}`; // use bcrypt in production
  }
}

class UserRepository {
  constructor(db) {
    this.db = db;
  }
  save({ name, email, hashedPassword }) {
    const user = { id: Date.now(), name, email, password: hashedPassword };
    this.db.insert("users", user);
    return user;
  }
}

// The handler becomes a thin coordinator — no business logic inside
function registerUserHandler(req, res, { validator, hasher, userRepository }) {
  try {
    validator.validate(req.body);
    const hashedPassword = hasher.hash(req.body.password);
    const user = userRepository.save({ ...req.body, hashedPassword });
    res.status(201).json({ message: "User created", userId: user.id });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
}

// Each class is now unit-testable without spinning up an HTTP server
// UserValidator tests: just call validate() directly
// PasswordHasher tests: just call hash() directly
// UserRepository tests: inject a mock db, call save() directly

Single Responsibility Principle visual 2


Common Mistakes

  • Splitting too far. SRP does not mean one method per class or one line per class. It means one reason to change. A class with ten methods that all relate to the same domain concept is fine. A class with three methods that serve three different stakeholders is a violation, even if it is small.

  • Confusing responsibility with feature. "User" is a feature. Validating a user, persisting a user, and emailing a user are three separate responsibilities. The feature lives in the domain. The responsibilities live in different layers of the architecture. SRP operates at the layer level, not the feature level.

  • Applying SRP in isolation. SRP works best alongside the other SOLID principles. A class that has one responsibility but depends directly on a concrete database class is still fragile. Combining SRP with the Dependency Inversion Principle (Lesson 7.5) is where the real maintainability gains appear.

  • Creating God orchestrators. When a God class is broken apart, all the orchestration logic sometimes migrates into the calling code, making the caller a new God class. The coordinator pattern used in the UserRegistrationService example above is the correct approach: a thin coordinator that only wires focused classes together, without containing business logic itself.

  • Mistaking file length for violation. A 300-line file is not automatically an SRP violation. The question is how many independent reasons to change it has. Some complex domains require long classes with a single, well-defined responsibility. Line count is a smell, not a rule.


Interview Questions

Q: What is the Single Responsibility Principle?

A class should have only one reason to change. More precisely, it should serve only one stakeholder or actor. If multiple independent teams or concerns drive changes to the same class, that class has multiple responsibilities. SRP says to separate those concerns into distinct classes so that a change driven by one team cannot accidentally break behavior owned by another.

Q: How do you detect an SRP violation in an existing codebase?

The fastest method is the "and" test: describe the class in one sentence. Every "and" in that description signals a potential additional responsibility. If you say "this class validates user input and hashes passwords and saves to the database," you have identified three responsibilities. Other signals include: classes with more than 200-300 lines, classes that import from unrelated dependency domains (database libraries and email clients in the same file), and classes that are changed frequently by different developers for unrelated reasons.

Q: What is a God class and why is it a problem?

A God class is a class that accumulates many unrelated responsibilities over time, typically because adding a new method to an existing class was faster than creating a new one. God classes cause four concrete problems: they are hard to unit test because mocking all dependencies is complex; they generate merge conflicts when multiple developers edit the same file for unrelated reasons; they make bugs harder to isolate because one failure could come from any of several responsibilities; and they resist reuse because tightly coupled concerns cannot be imported independently.

Q: Does applying SRP mean every class should have only one method?

No. SRP is about reasons to change, not method count. A class can have ten methods and still satisfy SRP, as long as all ten methods relate to the same responsibility and would change for the same reason. For example, an Order class might have addItem(), removeItem(), getTotal(), and updateStatus(). All four methods belong to the same domain concept and change when order behavior requirements change. That is one responsibility with multiple methods, which is perfectly correct.

Q: How does SRP relate to testability?

Classes that follow SRP are dramatically easier to unit test. A class with one responsibility has a narrow dependency set. You inject one or two mock objects and test the class behavior in isolation. A God class with five responsibilities requires mocking five different dependencies before a single test can run. In practice, teams often discover SRP violations by noticing that writing a unit test for a class requires an unreasonable amount of setup. Difficulty in testing is one of the strongest signals that a class has too many responsibilities.


Quick Reference Cheat Sheet

SINGLE RESPONSIBILITY PRINCIPLE — QUICK REFERENCE
---------------------------------------------------------------------------
Definition        A class should have only one reason to change.
                  One class serves one stakeholder or actor.

The "and" test    Describe the class in one sentence.
                  Every "and" = potential SRP violation.
                  Example: "validates AND saves AND emails" = 3 violations.

God class         A class that accumulates many responsibilities over time.
                  Signs: 300+ lines, unrelated imports, frequent edits
                  by multiple different teams.

Key benefits      - Each class is independently testable
                  - Changes are isolated (change one, don't break others)
                  - Reduces merge conflicts
                  - Enables easier code reuse

Common pitfalls   - Splitting too far (one method per class is wrong)
                  - Confusing feature with responsibility
                  - Creating God orchestrators during refactoring

The "and" test in practice
---------------------------------------------------------------------------
VIOLATION:  "UserService validates users AND saves them AND sends email"
FIX:        UserValidator / UserRepository / WelcomeEmailSender

VIOLATION:  "ReportService generates reports AND emails them"
FIX:        ReportGenerator / ReportEmailDelivery

VIOLATION:  "ProductManager validates AND prices AND formats products"
FIX:        ProductValidator / PricingCalculator / ProductFormatter

NO VIOLATION: "Order represents an order and manages its state"
              (all methods relate to the same domain concept)

Before/After mental model
---------------------------------------------------------------------------
BEFORE (God class):
  class UserService {
    validateInput()       // business rules team
    hashPassword()        // security team
    saveToDatabase()      // database team
    sendWelcomeEmail()    // marketing team
    logActivity()         // devops team
  }
  -- Five reasons to change. Five teams can break it.

AFTER (SRP applied):
  class UserValidator       { validate() }
  class PasswordHasher      { hash() }
  class UserRepository      { save(), findByEmail() }
  class WelcomeEmailSender  { send() }
  class RegistrationLogger  { logSuccess(), logFailure() }
  class UserRegistrationService { register() } -- thin coordinator only

Detecting violations — warning signals
---------------------------------------------------------------------------
  - File exceeds 200-300 lines
  - Class imports from unrelated domains (database + email in same file)
  - Class description requires "and" to be complete
  - Same file edited by multiple developers for unrelated reasons
  - Unit test requires mocking more than 2-3 dependencies
  - Class name ends in "Manager", "Handler", "Helper", "Utils", "Service"
    with a broad scope (not always a violation, but always worth checking)

SOLID overview — where SRP fits
---------------------------------------------------------------------------
  S — Single Responsibility  (this lesson)
  O — Open/Closed            (Lesson 7.2)
  L — Liskov Substitution    (Lesson 7.3)
  I — Interface Segregation  (Lesson 7.4)
  D — Dependency Inversion   (Lesson 7.5)
---------------------------------------------------------------------------

Previous: Lesson 6.4 Next: Lesson 7.2


This is Lesson 7.1 of the OOP Interview Prep Course — 8 chapters, 41 lessons.

On this page