Lesson 32 of 54

SOLID Principles in Practice

Single Responsibility Principle (SRP)

The Single Responsibility Principle is the most important and most misunderstood of the SOLID principles.

Many developers think it means "a class should do one thing." That's not quite right. Robert C. Martin's actual definition is:

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

A "reason to change" comes from a stakeholder—someone who might request modifications. If different people or departments could request different types of changes to your class, it has multiple responsibilities.

The Problem

Consider this class:

Typescript
class Employee {
  calculatePay(): number {
    // Payroll rules
  }
  
  reportHours(): string {
    // HR reporting format
  }
  
  saveToDatabase(): void {
    // Database persistence
  }
}

Who might request changes to this class?

  • CFO/Accounting: "Change how overtime is calculated"
  • HR: "Change the hours report format"
  • DBA/IT: "Change from Postgres to MongoDB"

Three different stakeholders, three reasons to change. That's three responsibilities in one class.

Why This Matters

Unwanted Coupling

When accounting requests a pay calculation change, you modify Employee. But now HR's tests might break. IT's deployment is affected. The change scope is larger than it should be.

Merge Conflicts

When two teams modify the same class for different reasons, you get merge conflicts and coordination overhead.

Cognitive Load

A class with multiple responsibilities is harder to understand. You have to load all the context into your head, even if you only care about one aspect.

The Solution: Separate by Stakeholder

Split the class by who might request changes:

Typescript
// Accounting owns this
class PayrollCalculator {
  calculatePay(employee: Employee): number {
    // Payroll rules
  }
}

// HR owns this
class HoursReporter {
  reportHours(employee: Employee): string {
    // HR reporting format
  }
}

// IT/Infrastructure owns this
class EmployeeRepository {
  save(employee: Employee): void {
    // Database persistence
  }
  
  find(id: string): Employee {
    // Database query
  }
}

// Just data, no behavior to change
class Employee {
  id: string;
  name: string;
  hoursWorked: number;
  hourlyRate: number;
}

Now:

  • Accounting changes only affect PayrollCalculator
  • HR changes only affect HoursReporter
  • Database changes only affect EmployeeRepository

Identifying Responsibilities

Ask: "Who would request a change to this code?"

Signs of Multiple Responsibilities

  1. Multiple import groups — Importing both UI libraries and database libraries suggests mixed concerns
  2. Methods that don't use each othercalculatePay() and saveToDatabase() are unrelated
  3. Different reasons to test — Some tests are about business rules, others about infrastructure
  4. The class keeps growing — Every new feature adds another method

The "And" Test

Describe what the class does. If you say "and," it might have multiple responsibilities:

  • "Handles user authentication and profile management" → Split
  • "Calculates order totals and generates invoices" → Split
  • "Parses JSON" → One responsibility, fine

Common Violations

God Objects

Typescript
class UserManager {
  createUser() { }
  authenticateUser() { }
  updateProfile() { }
  sendWelcomeEmail() { }
  generateReport() { }
  syncWithCRM() { }
  backupUserData() { }
}

This "Manager" does everything related to users. It should be split into UserRepository, AuthService, ProfileService, UserNotifier, etc.

UI + Business Logic

Typescript
class OrderForm {
  validateInput() { }           // UI validation
  calculateTotal() { }           // Business logic
  renderConfirmation() { }       // UI rendering
  saveOrder() { }                // Persistence
}

Mix of UI, business rules, and persistence.

Data + Behavior

Typescript
class User {
  name: string;
  email: string;
  
  sendEmail(subject: string, body: string) {
    // Actually sends email
  }
}

Should User know how to send emails? Or should an EmailService handle that?

Practical Application

Start with Cohesion

Methods that use the same data belong together. If calculatePay() uses hourlyRate and hoursWorked, but sendEmail() uses email, they have different cohesion.

Group by Actor

Identify who uses each method. Group methods by actor.

Extract When It Hurts

Don't preemptively split everything. When you feel the pain of multiple responsibilities—merge conflicts, unexpected test failures, hard-to-understand classes—that's when you split.

The Trade-off

SRP creates more classes. More files. More indirection.

Is this always better? No.

For a simple CRUD app, a UserController that handles all user operations might be fine. The stakeholder is just "the dev team," and the codebase is small.

But as the system grows, as teams specialize, as business rules get complex—SRP becomes essential.

Testing Connection

SRP makes testing easier:

  • PayrollCalculator can be tested with unit tests against payroll rules
  • EmployeeRepository can be tested against a test database
  • No need to mock unrelated dependencies

When a class is hard to test because it requires many mocks, it probably has multiple responsibilities.


Key insight: SRP isn't about "doing one thing"—it's about having one reason to change. Identify who might request changes to your code. If different stakeholders could request different changes, split the class. This reduces coupling, prevents merge conflicts, and makes code easier to understand.