Published on

Mastering the Single Responsibility Principle (SRP) in Software Design

Definition:

"A class should have only one reason to change."

Explanation: A class or module should do one thing and do it well. If a class is handling multiple responsibilities, a change in one responsibility can potentially impact others, leading to fragile and tightly coupled code.

🎯 Why Is SRP Important?

Here are some reasons why SRP is crucial in modern software development:

  • πŸ”§ Maintainability: Small, focused classes are easier to understand and maintain.
  • πŸ§ͺ Testability: A class with a single responsibility has fewer dependencies, making unit testing straightforward.
  • πŸ” Reusability: Focused classes can be reused in different parts of the application.
  • πŸ“ˆ Scalability: As your codebase grows, SRP makes it easier to extend the functionality without introducing bugs.

🚩 How to Detect the Violation of SRP

Violations of SRP usually emerge in the following scenarios:

  • A class has more than one reason to change (e.g., UI logic and business logic in the same class).
  • You see too many methods handling different concerns.
  • The constructor injects many dependencies (often more than 5–6).
  • Frequent changes to a class from multiple developers with different goals.
  • Difficulty in writing isolated unit tests.

Code Smells indicating SRP violation:

  • God Classes (classes with 800+ lines of code)
  • Too many if-else or switch-case statements
  • Duplicated code in multiple classes
  • Commented sections separating logic (e.g., // database logic, // UI logic)

πŸ‘Ž Polluted Code Example (SRP Violation)

class UserService {
  constructor(private db: Database, private emailService: EmailService) {}

  registerUser(user: User) {
    // Save to database
    this.db.save(user);

    // Send welcome email
    this.emailService.sendWelcomeEmail(user.email);
  }
}

❌ The above class does two things:

  1. Handles persistence.
  2. Handles communication (email sending).

βœ… Refactored Code (Following SRP)

class UserRepository {
  constructor(private db: Database) {}

  save(user: User) {
    this.db.save(user);
  }
}

class WelcomeEmailService {
  constructor(private emailService: EmailService) {}

  send(user: User) {
    this.emailService.sendWelcomeEmail(user.email);
  }
}

class UserRegistrationService {
  constructor(
    private userRepository: UserRepository,
    private welcomeEmailService: WelcomeEmailService
  ) {}

  register(user: User) {
    this.userRepository.save(user);
    this.welcomeEmailService.send(user);
  }
}

βœ… Now each class has a single responsibility:

  • UserRepository: Persistence
  • WelcomeEmailService: Communication
  • UserRegistrationService: Orchestration

πŸ› οΈ Techniques to Apply SRP

  1. Use Interfaces Abstract responsibilities using interfaces (ISP helps here too).

  2. Apply Composition Over Inheritance Delegate responsibilities to other smaller classes.

  3. Group Code by Responsibility, Not Layer Avoid only grouping by "Controllers", "Services", "Repositories". Instead, consider "Features".

  4. Use Dependency Injection Make dependencies explicit and manageable.

  5. Keep Classes Small (prefer < 100 LOC) Break up long classes and isolate concerns.

  6. Extract Functions or Services If a function within a class does something unrelated, extract it.

🧭 When to Refactor for SRP

  • During onboarding, new developers say: β€œI don't know what this class is supposed to do.”
  • During unit testing, too many mocks are required.
  • When features are breaking existing functionality.
  • When code duplication is becoming unmanageable.

🧱 Bonus: SRP in Domain-Driven Design (DDD)

In DDD, Entities, Value Objects, Repositories, and Services are natural outcomes of applying SRP. DDD encourages high cohesion and low coupling, perfectly aligned with SRP.

🧩 Advanced Problem: Order Checkout in an E-Commerce System

In large-scale systems like an e-commerce platform, a single use case (like order checkout) can become a God class nightmare β€” trying to do too much: validating a cart, applying discounts, saving to DB, charging payment, sending emails, etc.

❌ Problem: Violating SRP in Checkout Service

class CheckoutService {
  constructor(
    private cartService: CartService,
    private discountService: DiscountService,
    private paymentGateway: PaymentGateway,
    private orderRepository: OrderRepository,
    private emailService: EmailService,
  ) {}

  checkout(userId: string) {
    const cart = this.cartService.getCart(userId);
    
    if (cart.items.length === 0) {
      throw new Error("Cart is empty");
    }

    const total = this.discountService.applyDiscount(cart);

    const paymentSuccess = this.paymentGateway.charge(userId, total);
    if (!paymentSuccess) {
      throw new Error("Payment failed");
    }

    this.orderRepository.save(cart);

    this.emailService.sendOrderConfirmation(userId);
  }
}

🚩 Why This Violates SRP?

This CheckoutService class has multiple responsibilities:

  1. Cart validation
  2. Applying discounts
  3. Charging payment
  4. Saving the order
  5. Sending notifications

That’s 5 reasons to change β€” making the class fragile, hard to test, and impossible to extend without risk.

βœ… Solution: Refactoring with SRP

πŸ”„ Step 1: Extract responsibilities into their own services

1. CartValidator

class CartValidator {
  validate(cart: Cart) {
    if (cart.items.length === 0) {
      throw new Error("Cart is empty");
    }
  }
}

2. DiscountCalculator

class DiscountCalculator {
  constructor(private discountService: DiscountService) {}

  calculate(cart: Cart): number {
    return this.discountService.applyDiscount(cart);
  }
}

3. PaymentProcessor

class PaymentProcessor {
  constructor(private paymentGateway: PaymentGateway) {}

  process(userId: string, amount: number): boolean {
    return this.paymentGateway.charge(userId, amount);
  }
}

4. OrderPersister

class OrderPersister {
  constructor(private orderRepository: OrderRepository) {}

  persist(cart: Cart) {
    this.orderRepository.save(cart);
  }
}

5. OrderNotifier

class OrderNotifier {
  constructor(private emailService: EmailService) {}

  notify(userId: string) {
    this.emailService.sendOrderConfirmation(userId);
  }
}

πŸ”„ Step 2: Compose the Orchestrator

class CheckoutService {
  constructor(
    private cartService: CartService,
    private cartValidator: CartValidator,
    private discountCalculator: DiscountCalculator,
    private paymentProcessor: PaymentProcessor,
    private orderPersister: OrderPersister,
    private orderNotifier: OrderNotifier,
  ) {}

  checkout(userId: string) {
    const cart = this.cartService.getCart(userId);

    this.cartValidator.validate(cart);

    const total = this.discountCalculator.calculate(cart);

    const paymentSuccess = this.paymentProcessor.process(userId, total);
    if (!paymentSuccess) {
      throw new Error("Payment failed");
    }

    this.orderPersister.persist(cart);

    this.orderNotifier.notify(userId);
  }
}

πŸ” Testing Becomes Easier

Example: Unit Testing PaymentProcessor

it("should charge the user correctly", () => {
  const mockGateway = { charge: jest.fn().mockReturnValue(true) };
  const processor = new PaymentProcessor(mockGateway);

  const result = processor.process("user123", 500);

  expect(result).toBe(true);
  expect(mockGateway.charge).toHaveBeenCalledWith("user123", 500);
});

No need to mock cart, email, or DB!

πŸ’‘ Pro Tip: Use UseCase Classes or Command Handlers

In Clean Architecture or CQRS, CheckoutService is called a Use Case / Command Handler, and should only orchestrate logic.


🚨 Bad Code: Violating SRP

In this example, we simulate a complex scenario: managing a user registration service that:

  • Validates user input
  • Creates a user
  • Sends a welcome email
  • Logs the activity
// userService.ts

type User = {
  name: string;
  email: string;
  password: string;
};

export class UserService {
  private users: User[] = [];

  register(user: User): string {
    if (!user.email.includes('@')) throw new Error('Invalid email');
    if (user.password.length < 6) throw new Error('Password too short');

    this.users.push(user);

    // Simulate sending email
    console.log(`Email sent to ${user.email}`);

    // Log registration
    console.log(`User ${user.email} registered.`);

    return 'User registered successfully';
  }
}

❌ What’s wrong?

  • Validation, persistence, email logic, and logging are all in one class.
  • This makes the class hard to test, hard to maintain, and not reusable.

βœ… Good Code: Applying SRP

We'll split responsibilities:

  1. UserValidator – validates user input
  2. UserRepository – persists users
  3. EmailService – sends emails
  4. Logger – handles logs
  5. UserService – orchestrates all the above

1. UserValidator.ts

export class UserValidator {
  validate(email: string, password: string): void {
    if (!email.includes('@')) throw new Error('Invalid email');
    if (password.length < 6) throw new Error('Password too short');
  }
}

2. UserRepository.ts

type User = {
  name: string;
  email: string;
  password: string;
};

export class UserRepository {
  private users: User[] = [];

  save(user: User): void {
    this.users.push(user);
  }

  getAll(): User[] {
    return this.users;
  }
}

3. EmailService.ts

export class EmailService {
  sendWelcomeEmail(email: string): void {
    console.log(`Email sent to ${email}`);
  }
}

4. Logger.ts

export class Logger {
  log(message: string): void {
    console.log(`LOG: ${message}`);
  }
}

5. UserService.ts

import { UserValidator } from './UserValidator';
import { UserRepository } from './UserRepository';
import { EmailService } from './EmailService';
import { Logger } from './Logger';

type User = {
  name: string;
  email: string;
  password: string;
};

export class UserService {
  constructor(
    private validator: UserValidator,
    private repository: UserRepository,
    private emailService: EmailService,
    private logger: Logger
  ) {}

  register(user: User): string {
    this.validator.validate(user.email, user.password);
    this.repository.save(user);
    this.emailService.sendWelcomeEmail(user.email);
    this.logger.log(`User ${user.email} registered.`);
    return 'User registered successfully';
  }
}

βœ… Jest Unit Tests

Create a test file UserService.test.ts

import { UserService } from './UserService';
import { UserValidator } from './UserValidator';
import { UserRepository } from './UserRepository';
import { EmailService } from './EmailService';
import { Logger } from './Logger';

describe('UserService', () => {
  let userValidator: UserValidator;
  let userRepository: UserRepository;
  let emailService: EmailService;
  let logger: Logger;
  let userService: UserService;

  beforeEach(() => {
    userValidator = new UserValidator();
    userRepository = new UserRepository();
    emailService = new EmailService();
    logger = new Logger();

    // Mock side effects
    jest.spyOn(emailService, 'sendWelcomeEmail').mockImplementation(() => {});
    jest.spyOn(logger, 'log').mockImplementation(() => {});

    userService = new UserService(
      userValidator,
      userRepository,
      emailService,
      logger
    );
  });

  it('should register user successfully', () => {
    const user = { name: 'Alice', email: 'alice@example.com', password: '123456' };

    const result = userService.register(user);

    expect(result).toBe('User registered successfully');
    expect(userRepository.getAll()).toContainEqual(user);
  });

  it('should throw error for invalid email', () => {
    const user = { name: 'Bob', email: 'bobexample.com', password: '123456' };

    expect(() => userService.register(user)).toThrow('Invalid email');
  });

  it('should throw error for short password', () => {
    const user = { name: 'Charlie', email: 'charlie@example.com', password: '123' };

    expect(() => userService.register(user)).toThrow('Password too short');
  });
});

🏁 Conclusion

The Single Responsibility Principle transforms complex, risky logic into modular, maintainable components. When applied to advanced domains like e-commerce, payment systems, and SaaS platforms, it dramatically reduces technical debt and boosts engineering velocity.

β€œBig classes break silently. Small classes fail loudlyβ€”and that's a good thing.”

The Single Responsibility Principle is not just a ruleβ€”it's a habit that leads to cleaner, more modular, and maintainable code. It helps you scale your architecture, make testing effortless, and reduce bugs.

BeforeAfter
One huge class doing 5+ thingsSmall, focused services
Hard to testEasy unit testing
Fragile to changeStable and maintainable
Tight couplingLow coupling, high cohesion