(This post was last updated on December 13, 2024.)

The Single Responsibility Principle is one of the SOLID principles, the most widely recognized software design guide. Yet, among these principles, the SRP is the most confusing one. There are a lot of misinterpretations and debates about this topic. Even Rober C. Martin, the originator of the SRP, acknowledged its ambiguity:

Of all the SOLID principles, the Single Responsibility Principle (SRP) might be the least well understood. That’s likely because it has a particularly inappropriate name.

This post aims to provide a clear illustration of the SRP, drawing primarily on the explanations of Uncle Bob.

Definition

First, let’s clarify a common misconception: The SRP does not mean “a software module should do one thing.” Nevertheless, as of this writing, the example on Wikipedia focuses on the functional perspective.

SRP example on Wikipedia

The formal definition is:

A software module should be responsible to one, and only one, actor.

To break this down:

  • Software module: Typically a class, or a data structure with a set of methods.
  • Actor: A person, team, group of people, who is the primary source of change for the system.
  • Responsibility: A reason to change which, in turn, is tied to an actor.

Remember this formula along the rest of the post:

responsibility == reason to change == actor (people)

What matters is people.

Example 1: People

Whether a module adheres to the SRP depends on how many actors it is responsible to. In other words, the same module may either conform to the SRP or violate it, within the context of people.

Context

Imagine technical writers in an organization who must follow a specific formatting standard—consistent headings, paragraphs, and styling. Any change to these formatting rules applies equally to all technical writers, making them a unified single actor driving changes.

Implementation

ContentFormatter adhering SRP

class ContentFormatter:
    def format_article(self, article: Article) -> str:
        # Format the article content with proper headings, paragraphs, and styling
        formatted = f"<h1>{article.title}</h1>\n<p>{article.body}</p>"
        return formatted

Here, ContentFormatter serves the needs of the technical writers alone. Since there is only one actor, the module adheres to the SRP.

Problem

Now suppose marketing writers begin using the ContentFormatter as well. Initially, their formatting needs match those of the technical writers, so this seems convenient. However, now we have two distinct groups—two different sets of potential requirements—using the same module. Each group can request unique changes for its own reasons.

This situation breaks the SRP because now the ContentFormatter serves two different actors, each with its own motivations and triggers for change.

ContentFormatter violating SRP

For instance, imagine marketing writers ask for a promotional banner to be added before the article title. You modify the class:

class ContentFormatter:
    def format_article(self, article: Article) -> str:
        # Add a promotional banner for marketing purposes
        formatted = f"<div class='promo-banner'>Exclusive Offer!</div><h1>{article.title}</h1>\n<p>{article.body}</p>"
        return formatted

As a result, the technical writers’ workflows—which relied on the original formatting—are now disrupted. A request driven by the marketing team inadvertently impacted the technical writers, demonstrating how having more than one actor can create unintended side effects.

The takeaway here is that the module may appear to remain a single cohesive unit and has not changed at all, but now violates the SRP by people who use it.

Solution

A practical solution is to split responsibilities into separate modules (or classes) based on the actors:

TechnicalContentFormatter and MarketingContentFormatter

class TechnicalContentFormatter:
    def format_article(self, article: Article) -> str:
        # Format technical documentation with standard headings and paragraphs
        formatted = f"<h1>{article.title}</h1>\n<p>{article.body}</p>"
        return formatted

class MarketingContentFormatter:
    def format_article(self, article: Article) -> str:
        # Format marketing content with a promotional banner
        formatted = f"<div class='promo-banner'>Exclusive Offer!</div><h1>{article.title}</h1>\n<p>{article.body}</p>"
        return formatted

This approach ensures that the TechnicalContentFormatter addresses only the technical writers’ needs, while MarketingContentFormatter caters exclusively to the marketing writers. A change requested by one group now affects only their dedicated module, preventing unwanted side effects for the other group.

This is the fundamental idea of the SRP; it is about people.

Example 2: Accidental Duplication

Adhering to the SRP helps prevent unnecessary coupling between different concerns, thereby reducing the risk of unexpected failures when requirements change.

Context

Consider an e-commerce service with the following requirements:

  • Two membership types: standard and premium.
  • Users requesting refunds or exchanges must pay the round-trip shipping cost.
  • Premium users do not pay the shipping cost for refunds or exchanges.

Within the organization, the Refund Department and Exchange Department are separate teams, each has their own specific requirements.

Implementation

Order

class Order:
    def __init__(self, id: int, user: User, payment_amount: int, shipping_cost: int):
        self.id = id
        self.user = user
        self.payment_amount = payment_amount
        self.shipping_cost = shipping_cost

    def refund(self):
        round_trip_shipping_cost = self.shipping_cost * 2
        if self.user.membership == Membership.premium:
            round_trip_shipping_cost = 0
        self.payment_amount = round_trip_shipping_cost
        ...

    def exchange(self):
        round_trip_shipping_cost = self.shipping_cost * 2
        if self.user.membership == Membership.premium:
            round_trip_shipping_cost = 0
        self.payment_amount += round_trip_shipping_cost
        ...

In this scenario, the Order class here is responsible to two distinct actors with different concerns:

  • The actor handling refunds (using the refund() method)
  • The actor handling exchanges (using the exchange() method)

Hence, this implementation violates the SRP.

Unlike the ContentFormatter in the previous example, these responsibilities appear isolated, since refunds and exchanges use separate methods. As long as these methods remain completely orthogonal, it might not seem problematic.

In linear algebra, two non-zero vectors are said to be orthogonal if their scalar product is zero. In software engineering, the word ‘orthogonal’ can be interpreted as ‘independent’ in the sense that a change in a module never affect the other.

Then, how can this application go wrong?

Problem

Suppose a developer wants to follow the DRY (Don’t Repeat Yourself) principle and refactors the code to remove the duplicated return shipping cost calculation:

class Order:
    def __init__(self, id: int, user: User, payment_amount: int, shipping_cost: int):
        self.id = id
        self.user = user
        self.payment_amount = payment_amount
        self.shipping_cost = shipping_cost

    def refund(self):
        return_shipping_cost = self._get_return_shipping_cost()
        self.payment_amount = return_shipping_cost
        ...

    def exchange(self):
        return_shipping_cost = self._get_return_shipping_cost()
        self.payment_amount += return_shipping_cost
        ....

    def _get_return_shipping_cost(self) -> int:
        return_shipping_cost = self.shipping_cost * 2
        if self.user.membership == Membership.premium:
            return_shipping_cost = 0
        return return_shipping_cost

Now both refund() and exchange() depend on the same helper method _get_return_shipping_cost(). While this successfully eliminates code duplication, it also tightly couples both operations to a single logic path.

Imagine the Exchange Department wants a new policy:

  • Standard users do not pay the shipping cost for exchanges.

A developer, unaware that the refund() method relies on the same logic, inadvertently modifies _get_return_shipping_cost():

class Order:
    ...

    def _get_return_shipping_cost(self) -> int:
        return_shipping_cost = self.shipping_cost * 2
        if self.user.membership in (Membership.premium, Membership.standard):
            return_shipping_cost = 0
        return return_shipping_cost

By including standard users in the free-return logic, refunds are also now free for standard users. This unintended consequence disrupts the Refund Department’s processes. A single change requested by the Exchange Department ended up causing trouble for another actor.

Solution

One possible solution to avoid these pitfalls is to split responsibilities into separate classes, each is responsible to only one actor:

Refunder and Exchanger

class OrderData:
    def __init__(self, id: int, user: User, payment_amount: int, shipping_cost: int):
        self.id = id
        self.user = user
        self.payment_amount = payment_amount
        self.shipping_cost = shipping_cost

class Refunder:
    def __init__(self, order: OrderData):
        self.order = order

    def refund(self):
        return_shipping_cost = self._get_return_shipping_cost()
        self.order.payment_amount = return_shipping_cost
        ...

    def _get_return_shipping_cost(self) -> int:
        return_shipping_cost = self.order.shipping_cost * 2
        if self.order.user.membership == Membership.premium:
            return_shipping_cost = 0
        return return_shipping_cost

class Exchanger:
    def __init__(self, order: OrderData):
        self.order = order

    def exchange(self):
        return_shipping_cost = self._get_return_shipping_cost()
        self.order.payment_amount += return_shipping_cost
        ...

    def _get_return_shipping_cost(self) -> int:
        return_shipping_cost = self.order.shipping_cost * 2
        if self.order.user.membership == Membership.standard:
            return_shipping_cost = 0
        return return_shipping_cost

Now, each class is responsible to exactly one actor. When the Exchange Department changes its policies, modifications happen only in the Exchanger class. The Refunder class remains untouched, preserving the original logic for refunds.

Meanwhile, the Order class now stores only order-related data. It could even be renamed to OrderData if it provides no additional logic.

Note that having multiple private methods does not violate SRP as long as they are collectively responsible to single actor. The key point is that if a module contains multiple methods that change for different reasons, it is better to separate those methods into distinct modules. This way, any accidental duplication can be effectively avoided.

Example 3: Shared Module

In most real-world applications, it’s rare for modules to operate completely independently while serving their respective actors. More commonly, modules rely on shared dependencies, and changes to them can impact multiple actors.

Context

Let’s revisit our e-commerce application from example 2. Now, imagine that when a refund or exchange occurs, the system should send an appropriate email notification to the user.

Implementation

NotificationService

class NotificationService:
    def send_notification(self, message: str):
        send_email(message)

class Refunder:
    def __init__(self, order: OrderData, notification_service: NotificationService):
        self.order = order
        self.notification_service = notification_service

    def refund(self):
        return_shipping_cost = self._get_return_shipping_cost()
        self.order.payment_amount = return_shipping_cost
        ...
        self.send_notification(message=f"[Order #{order_id}] Your refund has been processed!")

class Exchanger:
    def __init__(self, order: OrderData, notification_service: NotificationService):
        self.order = order
        self.notification_service = notification_service

    def exchange(self):
        return_shipping_cost = self._get_return_shipping_cost()
        self.order.payment_amount += return_shipping_cost
        ...
        self.send_notification(message=f"[Order #{order_id}] We've received your exchange request!")

In this scenario, the NotificationService is a shared module that is injected into both the Refunder and Exchanger.

Problem

While the Refunder and Exchanger class follows the SRP individually, the NotificationService does not: it “ultimately” serves two different groups (the Refund Department and the Exchange Department). For example, suppose the Exchange Department requests that exchange notifications be sent via an in-app message rather than email. A developer might modify the send_notification method to send an in-app message, unaware that it’s also used by Refunder:

class NotificationService:
    def send_notification(self, message: str):
        send_inapp_message(message)

After this change, users expecting an email notification for refunds will no longer receive one. The Refund Department will later receive complaints from customers who didn’t get the information they needed. Although the Exchange Department is now satisfied with the in-app message, the unintended consequence is that the Refund Department’s process was inadvertently disrupted. (While you might argue that refund requests would also get in-app messages, that’s not the point. The principle is that changes meant for one actor should not negatively impact another.)

A naive attempt to avoid this problem might be:

class NotificationService:
    def send_email(self, message: str):
        ...

    def send_inapp_message(self, message: str):
        ...

However, as we learned from the previous examples, this approach doesn’t change the fact that the NotificationService is responsible to two actors, possibly leading to accidental duplication issue and further entanglement.

Solution

The most straightfoward and simplest way is to split the notification logic into separate completely independent classes, each dedicated to one actor’s preferred communication channel:

EmailService and InAppMessageService

class EmailService:
    def send(self, message: str):
        ...

class InAppMessageService:
    def send(self, message: str):
        ...

This way, EmailService is responsible solely to the Refund Department, while InAppMessageService is responsible solely to the Exchange Department.

If you think about it, every module should belong to a single actor, maintaining clear boundaries:

NotificationService Interface

Another possible solution is to introduce abstract interfaces to unify the similarity yet separate the variations:

NotificationService Interface

from abc import ABC, abstractmethod


class NotificationService(ABC):
    @abstractmethod
    def send(self, message: str):
        ...

class EmailService(NotificationService):
    def send(self, message: str):
        ...

class InAppMessageService(NotificationService):
    def send(self, message: str):
        ...

There are some trade-offs in this approach compared to the previous solution. On the one hand, this design can increase maintenance overhead, as every change to an abstract interface requires corresponding changes across all its concrete implementations.

On the other hand, using abstractions allows for flexible substitution of dependencies as long as the abstractions remain stable. For example, if the Refund Department later requests in-app notifications instead of emails, the developer only needs to swap out the EmailService for an InAppMessageService without breaking the system.

This is what’s called the Dependency Inversion Principle (DIP), the last of the SOLID principles. The idea of DIP is based on the notion that interfaces are less volatile than implementations.

Here’s a question: does the NotificationService here violate the SRP? Or more generally, are abstractions considered modules? The answer is “No”. An abstraction is just a conceptual contract. It specifies a set of interfaces or behaviors without tying them to a particular implementation. This means that abstractions, by their very nature, should not carry responsibilities that could be violated.

Conclusion

In my view, the SRP is an attempt of Uncle Bob to minimize the impact of changes driven by people. Yet he named it Single Responsibility Principle. Personally, I think it would have been easier to understand if it had been named the Single Actor Principle. (Though its acronym may conflict with that of Stable Abstractions Principle.)

At its core, the SOLID principles is about cohesion and coupling. The SRP in particular, is grounded in one key idea: we do not want to inadvertently break things. By understanding this principle, we gain a clearer perspective on how to effectively separate concerns.

References