- Published on
Mastering the Single Responsibility Principle (SRP) in Software Design
- π― Why Is SRP Important?
- π© How to Detect the Violation of SRP
- π Polluted Code Example (SRP Violation)
- β Refactored Code (Following SRP)
- π οΈ Techniques to Apply SRP
- π§ When to Refactor for SRP
- π§± Bonus: SRP in Domain-Driven Design (DDD)
- π§© Advanced Problem: Order Checkout in an E-Commerce System
- β Solution: Refactoring with SRP
- π Testing Becomes Easier
- π‘ Pro Tip: Use UseCase Classes or Command Handlers
- π¨ Bad Code: Violating SRP
- β Good Code: Applying SRP
- β Jest Unit Tests
- π Conclusion
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:
- Handles persistence.
- 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
: PersistenceWelcomeEmailService
: CommunicationUserRegistrationService
: Orchestration
π οΈ Techniques to Apply SRP
Use Interfaces Abstract responsibilities using interfaces (ISP helps here too).
Apply Composition Over Inheritance Delegate responsibilities to other smaller classes.
Group Code by Responsibility, Not Layer Avoid only grouping by "Controllers", "Services", "Repositories". Instead, consider "Features".
Use Dependency Injection Make dependencies explicit and manageable.
Keep Classes Small (prefer < 100 LOC) Break up long classes and isolate concerns.
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:
- Cart validation
- Applying discounts
- Charging payment
- Saving the order
- 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
PaymentProcessor
Example: Unit Testing 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:
- UserValidator β validates user input
- UserRepository β persists users
- EmailService β sends emails
- Logger β handles logs
- UserService β orchestrates all the above
UserValidator.ts
1. 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');
}
}
UserRepository.ts
2. 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;
}
}
EmailService.ts
3. export class EmailService {
sendWelcomeEmail(email: string): void {
console.log(`Email sent to ${email}`);
}
}
Logger.ts
4. export class Logger {
log(message: string): void {
console.log(`LOG: ${message}`);
}
}
UserService.ts
5. 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.
Before | After |
---|---|
One huge class doing 5+ things | Small, focused services |
Hard to test | Easy unit testing |
Fragile to change | Stable and maintainable |
Tight coupling | Low coupling, high cohesion |