Skip to main content

S — Single Responsibility Principle

Editorial

Introduction

Think about a Swiss Army knife. It has a blade, scissors, a screwdriver, a bottle opener, and a dozen other tools crammed into one handle. It is convenient to carry, but would you use it to perform surgery? Would you use it to build furniture? For any serious task, you reach for a dedicated tool — one that does its job exceptionally well.

Classes in software work the same way. A class that tries to do everything — handle user input, calculate business logic, format output, and save to a database — might work, but it becomes a liability the moment anything needs to change.

The Single Responsibility Principle (SRP) is the first and arguably most intuitive of the SOLID principles. It states:

A class should have only one reason to change.

This does not mean a class should have only one method. It means a class should be responsible for one cohesive concern — one axis of change. When a requirement about that concern changes, this class changes. When a requirement about something else changes, this class stays untouched.

SRP is the foundation that all other SOLID principles build on. If your classes have clear, focused responsibilities, it becomes much easier to extend them (OCP), substitute them (LSP), keep their interfaces lean (ISP), and depend on abstractions (DIP). We saw in the previous tutorial how the monolithic OrderProcessor suffered from rigidity, fragility, and immobility — SRP is the first step to curing those symptoms.

Single Responsibility Principle as a violation-to-fix transformation. The violation is one Employee God class with three responsibilities owned by three actors: calculatePay() owned by Accounting (changes when pay rules change), generateReport() owned by Management (changes when report format changes), and saveToDatabase() owned by IT/DevOps (changes when storage technology changes) — three reasons to change, so every edit risks the other two. The fix extracts each into a focused class: Employee (data), PayCalculator (Accounting), EmployeeReportGenerator (Management), and EmployeeRepository (IT/DevOps), each with exactly one reason to change. The Stakeholder Test: ask who might request a change to a class; if more than one unrelated team, it does too much.

What 'One Reason to Change' Actually Means

The phrase "one reason to change" is often misunderstood. It does not mean one function or one method. It means one stakeholder or one area of concern that would cause the class to be modified.

Robert C. Martin, who formulated SRP, later refined it as:

A class should be responsible to one, and only one, actor.

An "actor" here is a group of people or a role that might request changes. For example:

  • The accounting team might request changes to how employee pay is calculated
  • The management team might request changes to how reports are formatted
  • The DevOps team might request changes to how data is persisted

If one class handles pay calculation, report generation, AND data persistence, then three different actors can cause that class to change. Each change risks breaking the other two concerns. That class has three responsibilities — three reasons to change.

A well-designed system assigns each responsibility to a separate class, so changes requested by one actor never risk breaking functionality owned by another.

The Stakeholder Test

Here is a practical way to test for SRP compliance. For each class, ask: "Who might ask for a change to this class?" If the answer involves more than one unrelated team, role, or business concern, the class likely violates SRP.

SRP Violation — The Employee Report Generator

Let us look at a class that violates SRP and then refactor it. This is the violation → fix pattern you will see for every SOLID principle in this section.

# VIOLATION: Employee class has THREE responsibilities

class Employee:
    def __init__(self, name: str, hourly_rate: float,
                 hours_worked: float):
        self.name = name
        self.hourly_rate = hourly_rate
        self.hours_worked = hours_worked

    # Responsibility 1: Calculate pay (Accounting team owns this)
    def calculate_pay(self) -> float:
        base_pay = self.hourly_rate * self.hours_worked
        if self.hours_worked > 40:
            overtime_hours = self.hours_worked - 40
            overtime_pay = overtime_hours * self.hourly_rate * 1.5
            return base_pay + overtime_pay
        return base_pay

    # Responsibility 2: Generate report (Management team owns this)
    def generate_report(self) -> str:
        return (
            f"Employee Report\n"
            f"Name: {self.name}\n"
            f"Hours: {self.hours_worked}\n"
            f"Pay: ${self.calculate_pay():.2f}\n"
            f"{'--- OVERTIME ---' if self.hours_worked > 40 else ''}"
        )

    # Responsibility 3: Save to database (IT/DevOps team owns this)
    def save_to_database(self) -> None:
        print(f"INSERT INTO employees (name, pay) "
              f"VALUES ('{self.name}', {self.calculate_pay()})")
// VIOLATION: Employee class has THREE responsibilities

public class Employee {
    private String name;
    private double hourlyRate;
    private double hoursWorked;

    public Employee(String name, double hourlyRate, double hoursWorked) {
        this.name = name;
        this.hourlyRate = hourlyRate;
        this.hoursWorked = hoursWorked;
    }

    // Responsibility 1: Calculate pay (Accounting)
    public double calculatePay() {
        double basePay = hourlyRate * hoursWorked;
        if (hoursWorked > 40) {
            double overtimeHours = hoursWorked - 40;
            double overtimePay = overtimeHours * hourlyRate * 1.5;
            return basePay + overtimePay;
        }
        return basePay;
    }

    // Responsibility 2: Generate report (Management)
    public String generateReport() {
        return String.format(
            "Employee Report%nName: %s%nHours: %.1f%nPay: $%.2f%n%s",
            name, hoursWorked, calculatePay(),
            hoursWorked > 40 ? "--- OVERTIME ---" : ""
        );
    }

    // Responsibility 3: Save to database (IT/DevOps)
    public void saveToDatabase() {
        System.out.printf(
            "INSERT INTO employees (name, pay) VALUES ('%s', %.2f)%n",
            name, calculatePay()
        );
    }

    public String getName() { return name; }
    public double getHourlyRate() { return hourlyRate; }
    public double getHoursWorked() { return hoursWorked; }
}

This Employee class has three reasons to change:

  1. Accounting changes the overtime formula → modify calculate_pay()
  2. Management wants a different report format → modify generate_report()
  3. IT switches from SQL to NoSQL → modify save_to_database()

The danger: when the accounting team requests a change to the pay calculation, the developer modifying calculate_pay() might accidentally break the report or the database save — because all three concerns live in the same class, share the same internal state, and are tested together.

Worse, consider a subtle bug: both generate_report() and save_to_database() call calculate_pay() internally. If the accounting team changes the overtime formula and calculate_pay() now requires a new parameter that was not previously needed, both methods break — even though management and DevOps requested nothing.

Applying SRP — The Refactored Design

The fix is to extract each responsibility into its own class. Each class now has exactly one reason to change and is owned by one actor.

# AFTER SRP: Each class has ONE responsibility

class Employee:
    """Pure data holder — represents an employee's core information."""

    def __init__(self, name: str, hourly_rate: float,
                 hours_worked: float):
        self.name = name
        self.hourly_rate = hourly_rate
        self.hours_worked = hours_worked


class PayCalculator:
    """Responsibility: Calculate employee pay.
    Owner: Accounting team.
    Reason to change: Pay rules change."""

    def calculate_pay(self, employee: Employee) -> float:
        base_pay = employee.hourly_rate * employee.hours_worked
        if employee.hours_worked > 40:
            overtime_hours = employee.hours_worked - 40
            overtime_pay = overtime_hours * employee.hourly_rate * 1.5
            return base_pay + overtime_pay
        return base_pay


class EmployeeReportGenerator:
    """Responsibility: Format employee reports.
    Owner: Management team.
    Reason to change: Report format changes."""

    def __init__(self, pay_calculator: PayCalculator):
        self.pay_calculator = pay_calculator

    def generate_report(self, employee: Employee) -> str:
        pay = self.pay_calculator.calculate_pay(employee)
        overtime_flag = (
            "--- OVERTIME ---" if employee.hours_worked > 40 else ""
        )
        return (
            f"Employee Report\n"
            f"Name: {employee.name}\n"
            f"Hours: {employee.hours_worked}\n"
            f"Pay: ${pay:.2f}\n"
            f"{overtime_flag}"
        )


class EmployeeRepository:
    """Responsibility: Persist employee data.
    Owner: IT/DevOps team.
    Reason to change: Storage technology changes."""

    def save(self, employee: Employee, pay: float) -> None:
        print(f"INSERT INTO employees (name, pay) "
              f"VALUES ('{employee.name}', {pay})")


# --- Usage ---
employee = Employee("Alice", hourly_rate=50.0, hours_worked=45)

calculator = PayCalculator()
pay = calculator.calculate_pay(employee)
print(f"Pay: ${pay:.2f}")

reporter = EmployeeReportGenerator(calculator)
print(reporter.generate_report(employee))

repository = EmployeeRepository()
repository.save(employee, pay)
// AFTER SRP: Each class has ONE responsibility

class Employee {
    private final String name;
    private final double hourlyRate;
    private final double hoursWorked;

    public Employee(String name, double hourlyRate, double hoursWorked) {
        this.name = name;
        this.hourlyRate = hourlyRate;
        this.hoursWorked = hoursWorked;
    }

    public String getName() { return name; }
    public double getHourlyRate() { return hourlyRate; }
    public double getHoursWorked() { return hoursWorked; }
}

// Owner: Accounting team. Changes when: pay rules change.
class PayCalculator {
    public double calculatePay(Employee employee) {
        double basePay = employee.getHourlyRate() * employee.getHoursWorked();
        if (employee.getHoursWorked() > 40) {
            double overtimeHours = employee.getHoursWorked() - 40;
            double overtimePay = overtimeHours * employee.getHourlyRate() * 1.5;
            return basePay + overtimePay;
        }
        return basePay;
    }
}

// Owner: Management team. Changes when: report format changes.
class EmployeeReportGenerator {
    private final PayCalculator payCalculator;

    public EmployeeReportGenerator(PayCalculator payCalculator) {
        this.payCalculator = payCalculator;
    }

    public String generateReport(Employee employee) {
        double pay = payCalculator.calculatePay(employee);
        StringBuilder report = new StringBuilder();
        report.append("Employee Report\n");
        report.append("Name: ").append(employee.getName()).append("\n");
        report.append("Hours: ").append(employee.getHoursWorked()).append("\n");
        report.append(String.format("Pay: $%.2f%n", pay));
        if (employee.getHoursWorked() > 40) {
            report.append("--- OVERTIME ---\n");
        }
        return report.toString();
    }
}

// Owner: IT/DevOps team. Changes when: storage technology changes.
class EmployeeRepository {
    public void save(Employee employee, double pay) {
        System.out.printf(
            "INSERT INTO employees (name, pay) VALUES ('%s', %.2f)%n",
            employee.getName(), pay
        );
    }
}

// --- Usage ---
public class Main {
    public static void main(String[] args) {
        Employee employee = new Employee("Alice", 50.0, 45);

        PayCalculator calculator = new PayCalculator();
        double pay = calculator.calculatePay(employee);
        System.out.printf("Pay: $%.2f%n", pay);

        EmployeeReportGenerator reporter =
            new EmployeeReportGenerator(calculator);
        System.out.println(reporter.generateReport(employee));

        EmployeeRepository repository = new EmployeeRepository();
        repository.save(employee, pay);
    }
}

Now each class has exactly one reason to change:

ClassResponsibilityOwnerChanges When
EmployeeHold employee dataData modeling teamCore data model changes
PayCalculatorCalculate payAccounting teamPay rules change
EmployeeReportGeneratorFormat reportsManagement teamReport format changes
EmployeeRepositoryPersist dataIT/DevOps teamStorage technology changes

A change requested by accounting cannot possibly break the report format or the database layer, because they live in separate classes with no shared logic. Each class can be tested independently, modified independently, and reused independently.

Visualization

Pick which team requests a change, flip between the monolithic Employee class and the SRP split, and watch the blast radius. In the monolith, any edit recompiles the whole Employee class and forces retesting the two unrelated responsibilities; under SRP the change lands in exactly one focused class and the rest stay untouched.

How to Identify SRP Violations

Recognizing when a class violates SRP is a skill that improves with practice. Here are practical signals to watch for:

Signal 1: The Class Has Too Many Reasons to Change

Ask yourself: "What kinds of requirements changes would make me modify this class?" If the answer involves more than one unrelated concern (e.g., business rules AND data formatting AND persistence), the class likely violates SRP.

Signal 2: The Class Name Includes "And" or "Manager"

Class names like OrderManager, UserService, DataHandler, or ApplicationController are often warning signs. If you struggle to describe what the class does in one sentence without using the word "and," it probably does too much. A well-named class reveals its single responsibility: PayCalculator, InvoiceFormatter, UserRepository.

Signal 3: Methods Do Not Cohesively Use the Same State

If a class has methods that do not use each other and do not share the same instance variables, they probably belong in separate classes. In our Employee example, generate_report() and save_to_database() had no logical connection — they just happened to live in the same class.

Signal 4: Different Methods Change at Different Rates

If the pay calculation logic is stable but the report format changes every quarter, those two concerns evolve at different rates. Bundling them together forces unnecessary retesting of stable code whenever the unstable part changes. This is a more subtle but equally important SRP signal.

Common Mistakes

Mistake 1: Taking SRP Too Far — The One-Method-Per-Class Extreme

The trap: Creating a NameGetter class, an HourlyRateGetter class, and a HoursWorkedGetter class because "each getter is a separate responsibility."

The reality: SRP is about separating concerns, not separating methods. Getters for an employee's data all belong to the same concern — representing employee information. A class with ten methods can still have a single responsibility if all ten methods serve the same cohesive purpose.

Mistake 2: Confusing SRP with "Small Classes"

The trap: Measuring SRP compliance by the number of lines or methods in a class. Splitting a 200-line class into ten 20-line classes that constantly call each other.

The reality: The metric is cohesion — do all the methods and fields in this class work toward the same purpose? A 200-line class with a single concern is better designed than ten 20-line classes with blurred responsibilities and tangled dependencies.

Mistake 3: Not Knowing Where to Draw the Line

The trap: Wondering whether PayCalculator should be further split into BasePayCalculator and OvertimePayCalculator because they handle "different kinds of pay."

The reality: Ask the stakeholder question — is there a different actor who would change base pay rules independently of overtime rules? If the same accounting team owns both and they always change together, keep them in one class. SRP is about actors and change, not about arbitrary decomposition.

When to Apply SRP

Apply SRP when:

  • A class is growing large and its methods serve different purposes for different stakeholders
  • Different team members would request changes to different parts of the same class
  • You find yourself changing one part of a class and needing to retest completely unrelated functionality
  • You want to reuse a piece of logic (e.g., tax calculation) but cannot extract it without bringing along unrelated code (e.g., email formatting)
  • Merge conflicts are frequent because multiple developers touch the same class for unrelated reasons

Be cautious when:

  • The application is small and the overhead of many tiny classes outweighs the benefit — a 50-line script does not need five classes
  • Splitting a class would create artificial dependencies where objects constantly pass data back and forth with no clear benefit
  • The class is a simple data container (a DTO or value object) with no behavior — splitting it makes no sense
  • You are in the early stages of exploration — premature splitting can be just as harmful as premature optimization

SRP in the Real World

SRP is not just an academic exercise — it is the principle behind many proven architectural patterns:

  • MVC (Model-View-Controller) separates data (Model), presentation (View), and input handling (Controller) — each is a distinct responsibility
  • Repository Pattern separates data access from business logic — the repository has one job: talk to the database
  • Service Layer separates business orchestration from controllers and data access
  • Microservices Architecture takes SRP to the system level — each microservice handles one bounded context

You will encounter SRP-driven decomposition in every design problem in the Design Problems course. When we design the Parking Lot system, the ParkingLot class manages spots, the TicketService handles tickets, and the PaymentProcessor handles payments — each with a single responsibility. The same pattern repeats in every well-designed system.

Key Takeaways

  • The Single Responsibility Principle says a class should have only one reason to change — meaning it should serve only one actor or stakeholder
  • SRP violations lead to fragile code where changes in one area break unrelated functionality in the same class
  • To apply SRP, identify the actors who might request changes and give each actor's concern its own class
  • SRP does not mean one method per class — it means one cohesive concern per class, measured by whether methods share the same purpose and change for the same reasons
  • SRP is the foundation of all other SOLID principles — it makes OCP, LSP, ISP, and DIP easier to achieve
  • In the next tutorial, we will explore the Open/Closed Principle — which builds directly on SRP by showing how to add new behavior to well-separated classes without modifying them