Skip to content

Single Responsibility Principle

Core Idea

Single Responsibility Principle states that a unit of code should carry one reason to change. A unit can be a class, function, module, or package. The rule targets change isolation. When one unit mixes many concerns, one new request can trigger edits in unrelated logic. That coupling raises defect risk and review cost.

SRP gives each unit one clear role. A clear role keeps intent readable. A clear role keeps test scope narrow. A clear role keeps refactors local.

Conceptual Overview

SRP uses one practical question. What event should force this unit to change. A strong design gives one consistent answer. A weak design gives many answers from different directions.

Example of mixed pressures:

  • A new domain rule changes validation logic
  • A storage migration changes repository behavior
  • A notification format update changes delivery code

When one class handles all three, that class changes for three independent reasons. That shape violates SRP.

A split design assigns each reason to one unit. The coordinator unit controls sequence. Computation units perform focused work. Infrastructure units handle external systems.

This split improves black box testing. Tests verify input and output per unit. Failures point to one concern. Debug paths become shorter.

All examples below use the shared handbook application context: Hypothetical Scenario.

SRP change pressure map

One multi purpose class attracts unrelated changes. Split units confine change to one boundary.

Computing History

David Parnas wrote in 1972 that modules should hide volatile design decisions. His work framed decomposition around expected change, not execution order. Robert C. Martin later presented SRP as part of SOLID guidance for object-oriented design.

Sources: Parnas (1972) and Martin (2003)

Quote

"Gather together those things that change for the same reasons. Separate those things that change for different reasons."

Source: Robert C. Martin, 2003

Generic Example: Coordinator and Computation Roles

Coordinator functions pick the next step. Computation functions perform data work. Each role needs a separate boundary.

from dataclasses import dataclass


@dataclass
class GroupReference:
    feature_key: str
    value: str


class CarPreferenceProfile:
    def __init__(self):
        self.preferences: list[GroupReference] = []

    def add_preference(self, new_preference: GroupReference) -> bool:
        existing = self.find_preference(new_preference)
        if existing:
            if self.needs_update(existing=existing, incoming=new_preference):
                return self.update_preference(new_preference)
            return True
        return self.create_preference(new_preference)

    def find_preference(self, candidate: GroupReference) -> GroupReference | None:
        for current in self.preferences:
            if candidate.feature_key == current.feature_key:
                if candidate.value == current.value:
                    return current
                raise ValueError("Feature key already exists with a different value")
            if candidate.value == current.value:
                return current
        return None

add_preference coordinates control flow. find_preference computes one focused lookup result. That split allows direct unit tests for each concern.

Coordinator versus computation functions

Coordinator and computation roles should not collapse into one large method.

Anti Pattern and Better Version

Non compliant:

class CarListingDigestService:
    def publish_daily_digest(self, listings: list[dict]) -> None:
        digest = self._build_digest(listings)
        self._save_digest(digest)
        self._send_digest_email(digest)

Issue:

  • One class owns formatting, persistence, and delivery
  • Three independent change sources hit one file
  • Test setup expands across unrelated concerns

Compliant:

class CarListingDigestBuilder:
    def build(self, listings: list[dict]) -> dict:
        return {"title": "Daily car deals", "items": listings}


class DigestRepository:
    def save(self, digest: dict) -> None:
        ...


class DigestNotifier:
    def send(self, digest: dict) -> None:
        ...


class CarListingDigestPublisher:
    def __init__(
        self,
        builder: CarListingDigestBuilder,
        repository: DigestRepository,
        notifier: DigestNotifier,
    ):
        self.builder = builder
        self.repository = repository
        self.notifier = notifier

    def publish(self, listings: list[dict]) -> None:
        digest = self.builder.build(listings)
        self.repository.save(digest)
        self.notifier.send(digest)

Each class now has one reason to change. CarListingDigestPublisher coordinates collaborators. Other units own their concern boundaries.

Testing Implications

SRP improves test precision. A test can target one behavior claim. The class under test exposes one focused role. Mocking pressure drops.

A coordinator test can use simple test doubles. A computation test can verify deterministic outputs. Failure signals stay specific.

This model aligns with one assert guidance. One concern per unit supports one claim per test.

This principle links to Simplicity First. Small responsibilities keep cognitive load low.

This principle links to Modularity and Composition. Modules compose cleanly when responsibility boundaries stay sharp.

This principle links to Abstraction and Boundaries. A clear role defines a clear boundary contract.

This principle links to Correctness and Testing. Narrow responsibilities produce narrow tests.

This principle links to Class Design. Class visibility and encapsulation support responsibility boundaries.

This principle links to The Multiple Assert Problem. Mixed responsibilities often create broad tests with mixed claims.

This principle links to Dependency Inversion. Responsibility boundaries pair well with abstraction boundaries.

Practice Checklist

  • Name one reason to change for each unit
  • Split units that answer multiple change reasons
  • Keep coordinator logic separate from computation logic
  • Keep infrastructure code outside domain units
  • Write tests that target one unit concern per case
  • Review pull requests for responsibility drift

Written by: Pedro Guzmán

See References for complete APA-style bibliographic entries used on this page.