Single Responsibility Principle - Explained with Examples
(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.
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
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.
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:
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
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:
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
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:
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:
Another possible solution is to introduce abstract interfaces to unify the similarity yet separate the variations:
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.