Single Responsibility Principle


The Principle#

The Single Responsibility Principle (SRP) states that a class should have only one reason to change. More specifically: a class should have only one responsibility, where a responsibility is defined as a reason to change.

This principle is about identifying the stable boundaries in your code and making sure each class fits neatly within one of those boundaries.

Understanding Responsibility#

A responsibility is a reason to change. If a class has multiple responsibilities, it has multiple reasons to change. Multiple reasons to change means multiple dependencies on that class for different purposes.

Consider a class that handles both User data and User billing:

class User {
  private name: string;
  private email: string;
  private creditCard: CreditCard;

  saveToDB(): void {
    // Save user data to database
  }

  chargeCard(amount: number): void {
    // Charge the user's credit card
  }

  sendBillingEmail(): void {
    // Send billing email to user
  }
}

This User class has three reasons to change:

  1. If user data structure changes (add a phone field)
  2. If billing logic changes (new payment provider)
  3. If email formatting changes (new email service)

When any of these change, the User class is modified. If the billing system changes, code that has nothing to do with billing (just needing the User’s name and email) might break.

The Cost of Multiple Responsibilities#

Multiple responsibilities create several problems:

Fragility#

When you modify one responsibility, you risk breaking another. Making a change to fix billing logic could introduce a bug in user data serialization. Tests for one responsibility might fail when you’re fixing an unrelated responsibility.

Inflexibility#

If you have multiple systems using the User class, each system needs all of User’s dependencies. A read-only view of user data still needs the billing system and email system installed, even though it won’t use them.

Testability#

Testing one responsibility requires setting up mocks for all other responsibilities. If User depends on CreditCardProcessor and EmailService, your tests for user data retrieval require mocking both of those systems.

Reusability#

Code that should be reusable becomes coupled to unrelated concerns. You can’t reuse billing logic without dragging in user data logic and email logic.

Identifying Responsibilities#

Responsibilities map to actors—groups of users or stakeholders whose changes drive the requirement to change the class.

In the User example above:

  • Database administrator wants to change user data structure
  • Finance team wants to change billing logic
  • Marketing team wants to change email templates

Different actors have different change drivers. When you identify that a class is being changed for reasons driven by different actors, that’s a signal of multiple responsibilities.

Refactoring to Follow SRP#

The solution is to break the multi-responsibility class into multiple single-responsibility classes:

class User {
  constructor(
    private name: string,
    private email: string
  ) {}

  getName(): string {
    return this.name;
  }

  getEmail(): string {
    return this.email;
  }
}

class UserRepository {
  save(user: User): void {
    // Save user data to database
  }
}

class BillingService {
  chargeCard(userId: string, amount: number): void {
    // Charge the user's credit card
  }
}

class EmailService {
  sendBillingEmail(userId: string): void {
    // Send billing email to user
  }
}

Now each class has one reason to change:

  • User: Changes when user data structure changes
  • UserRepository: Changes when storage mechanism changes
  • BillingService: Changes when billing logic changes
  • EmailService: Changes when email formatting changes

Cohesion and SRP#

The opposite of SRP violation is cohesion—the degree to which elements in a class are related. High cohesion means elements work together for a common purpose. Low cohesion means elements are only loosely related.

SRP is about achieving high cohesion. When you separate concerns properly, related logic stays together in one class, and unrelated logic separates into different classes.

Real-World Example: Report Generation#

Consider a report generation system:

// Before: SRP Violation
class SalesReport {
  generateReport(data: SalesData[]): void {
    // Validate data
    // Calculate totals
    // Format as PDF
    // Send email
    // Save to database
  }
}

// After: SRP Compliant
class ReportCalculator {
  calculate(data: SalesData[]): ReportData {
    // Validate data
    // Calculate totals
    return reportData;
  }
}

class PDFFormatter {
  format(data: ReportData): PdfDocument {
    // Format as PDF
    return pdf;
  }
}

class EmailService {
  send(pdf: PdfDocument): void {
    // Send email
  }
}

class ReportRepository {
  save(data: ReportData): void {
    // Save to database
  }
}

Now if the email service changes, you don’t need to modify the report calculation logic. If PDF formatting changes, calculation isn’t affected.

SRP and the Long Method#

Long methods often indicate multiple responsibilities. A method with many lines usually does multiple things. Similarly, a method with many parameters often handles multiple concerns.

When you see methods with 50+ lines or 10+ parameters, consider whether the method should be split based on different responsibilities.

SRP and Extensibility#

SRP makes code more extensible. When responsibilities are separate, adding new functionality means creating new classes rather than modifying existing ones.

If you want to add a new report format (Excel instead of PDF), you create a new formatter class instead of modifying the existing report generation class. This protects existing functionality from unintended side effects.

The Grain Size Question#

How small should classes be? Should each class have exactly one public method?

No. The grain size should match your domain. If a repository has find(), save(), delete(), and query() methods, that’s okay if they’re all about data access. That’s one responsibility.

But a class with getUser(), chargeCard(), and sendEmail() is mixing concerns. That’s too coarse.

The question to ask: would two different teams need to modify this class? If yes, it’s too coarse and needs separation.

SRP in Teams#

SRP becomes more important as teams grow. When one team owns the User class and another team owns Billing, changes are coordinated between teams. Conflicts are prevented by clear responsibility boundaries.

SRP and Dependency Injection#

SRP often leads to more classes with injected dependencies. This is good—it creates flexibility. New implementations can be plugged in without changing the class that depends on them.

However, don’t use SRP as an excuse for premature abstraction. Inject dependencies where variation is likely, not everywhere.

Conclusion#

The Single Responsibility Principle creates code that’s easier to understand, test, and change. By ensuring each class has only one reason to change, you reduce fragility and increase flexibility.

Look for classes with multiple reasons to change. Break them into focused classes that each do one thing well. Your code will become more maintainable, more testable, and easier to reason about.

Remember: the point isn’t having small classes. The point is having classes where each element belongs together and external drivers of change are minimal.