Single Responsibility Principle - explained with examples
The single-responsibility principle is one of the SOLID principles, the most widely accepted software design guide. I intentionally added “widely accepted” to the sentence because they may not be the always-right answers to all problems, but they do work best when properly complied. There are many good reasons why we should conform to these principles, but I prefer to make them into one simple reason - We do not have to waste time trying to understand the code and trying not to make mistakes.
This post is specifically about the SRP which is really the hardest one of the SOLID principles. I’ve read a couple of books and a number of articles about it, but they all seem to explain this topic in somewhat different aspects. (They really are, though)
I am writing this post to synthesize those explanations and illustrate it with some examples from a practical perspective.
Definition
It all comes down to one definition:
A software module should be responsible to only one client.
The word “client” is also called as “user” or “actor”. It is the one that uses that software module. The word “software module” can have different scopes, but generally, it refers to a class - a data structure with a set of methods.
The word “responsible” part of the definition can be best understood with the following examples:
Example 1: Low Cohesion
The highest cohesive software module is responsible to one, and only one, client.
Context
An e-commerce application has the following requirements:
- There are three types of user memberships: basic, standard and premium.
- To refund or exchange, users should pay the round trip shipping cost.
- Premium users can refund or exchange products for free.
A team of devs implements the app:
from enum import Enum
class Membership(str, Enum):
basic = "basic"
standard = "standard"
premium = "premium"
class User:
def __init__(self, id: int, membership: Membership):
self.id = id
self.membership = membership
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
And the Order
class is responsible to two different clients:
- a client that is responsible to refunds, it would call
refund()
method. - a client that is responsible to exchanges, it would call
exchange()
method.
By the definition of it, we can say this class violates the SRP because it is not responsible to one client but two.
Then, how can this application go wrong?
Problem
Let’s suppose that a developer, who knows about a DRY(Don’t Repeat Yourself) principle, modifies Order
in which it uses get_return_shipping_cost()
to avoid code duplication:
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
After that, the management department decides to add a new policy:
- Standard users can exchange products for free
To update the application, a developer reads the code, modifies get_return_shipping_cost()
without realizing that refund()
method is also coupled with it:
class Order:
...
def get_return_shipping_cost(self) -> int:
return_shipping_cost = self.shipping_cost * 2
if self.user.membership == Membership.standard:
return_shipping_cost = 0
return return_shipping_cost
The new version of the app is deployed, now all standard users become able to refund products for free just like premium users.
A client that is responsible to refunds didn’t want this to happen, the side effect is reported shortly thereafter, the developer urgently hotfixes the code or rollbacks the application and regrets that he or she didn’t spend much time testing or reviewing the code before the update.
Solution
The most comprehensible solution to this problem is to distribute each responsibility by creating new classes, each of them is responsible to only one client:
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
class Refunder:
def __init__(self, order: Order):
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: Order):
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 Refunder
and Exchanger
have one reason to change.
To implement the new policy by the management dept, the developer only needs to modify the Exchanger
class.
And this does not impact on the Refunder
because the Exchanger
class is responsible to only one client.
Meanwhile, the Order
class is only responsible to store the order-related data.
This may be named OrderData
if it doesn’t have any method, for example.
The developer who accidently modified the Order
class before refactoring would not have worried about such side effect if the code was written based on the SRP.
Do also note that Refunder
and Exchanger
have more than one method.
Assuming that every class should do one thing is a misapprehension of the SRP.
A class does not have to have a single method and can do more than one thing as long as it is responsible to one, and only one, client.
Example 2: Orthogonal Methods
If a software module has orthogonal methods, there’s a good chance that those methods are better off to be separated into independent software modules.
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’.
Context
A team of devs is tasked to create an application that transfers employee’s payroll. And there is a couple more requirements:
- It first needs to calculate the payroll of an employee before transferring it.
- After the transfer, it should report to the CFO.
To implement that, the dev team creates a software module (or a class) named PayrollTransferrer
, and it depends on Employee
class that has already been implemented by other developers.
The following are the UML diagram of the two modules and the source code written in Python:
# employee.py
class Employee:
def __init__(self, id: int, name: str, weekly_salary: int, account_number: str):
self.id = id
self.name = name
self.weekly_salary = weekly_salary
self.account_number = account_number
# payroll.py
from employee import Employee
class PayrollTransferrer:
def calculate_payroll(self, employee: Employee) -> int:
print(f"Calculating payroll of employee {employee.id}")
state_tax = employee.weekly_salary * 0.1
health_insurance = employee.weekly_salary * 0.05
return employee.weekly_salary - state_tax - health_insurance
def transfer_payroll(self, payroll: int, employee: Employee):
print(f"Transferring {payroll} to {employee.account_number}")
def report_payroll(self, payroll: int, employee: Employee):
print(f"Reporting payroll({payroll}) of an employee {employee.id} to the CFO")
Let’s see how it works with Python interpreter:
>>> from employee import Employee
>>> from payroll import PayrollTransferrer
>>> employee = Employee(id=1, name='Mienxiu', weekly_salary=2000, account_number='012345-67-890123')
>>> payroll_transferrer = PayrollTransferrer()
>>> payroll = payroll_transferrer.calculate_payroll(employee=employee)
Calculating payroll of employee 1
>>> payroll_transferrer.transfer_payroll(payroll=payroll, employee=employee)
Transferring 1700.0 to 012345-67-890123
>>> payroll_transferrer.report_payroll(payroll=payroll, employee=employee)
Reporting payroll(1700.0) of an employee 1 to the CFO
Apparently the three methods of the PayrollTransferrer
are orthogonal to the others because they can be used independently of the rest. Therefore, the PayrollTransferrer
violates the SRP in that it has three different reasons to change.
The client of the PayrollTransferrer
is going to be used by the HR department.
Let’s see why this is a bad design.
Problem
Assume that we have a new tax advisor that also needs to calculate the payroll:
To implement that, a developer creates a new class named TaxAdvisor
and decides to use PayrollTransferrer
:
# tax_advisor.py
from employee import Employee
from payroll import PayrollTransferrer
class TaxAdvisor:
def calculate_payroll(self, payroll_transferrer: PayrollTransferrer, employee: Employee) -> int:
return payroll_transferrer.calculate_payroll(employee)
As all the methods in PayrollTransferrer
are orthogonal, the TaxAdvisor
only needs to know about the calculate_payroll()
but not others.
The extra methods other than calculate_payroll()
could only make the developer confusing.
This problem might seem trivial at first glance, but we all have experience in wasting time reading unncessary lines of code just because they were carried with the modules we needed.
Moreover, does it make sense for PayrollTransferrer
to calculate the payroll?
Basically and ideally, the name of a class should describe its responsibility so that readers can quickly identify what that class does. Furthermore, if a class has only one responsibility, naming it would not be that hard task.
Solution
We can refactor it by encapsulating each method into a different class so that all of them are descriptive of what they specifically do:
# payroll.py
from employee import Employee
class PayrollCalculator:
def calculate_payroll(self, employee: Employee) -> int:
print(f"Calculating payroll of employee {employee.id}")
state_tax = employee.weekly_salary * 0.1
health_insurance = employee.weekly_salary * 0.05
return employee.weekly_salary - state_tax - health_insurance
class PayrollTransferrer:
def transfer_payroll(self, payroll: int, employee: Employee):
print(f"Transferring {payroll} to {employee.account_number}")
class PayrollReporter:
def report_payroll(self, payroll: int, employee: Employee):
print(f"Reporting payroll({payroll}) of an employee {employee.id} to the CFO")
class PayrollSystem:
def __init__(
self,
payroll_calculator: PayrollCalculator,
payroll_transferrer: PayrollTransferrer,
payroll_reporter: PayrollReporter,
employee: Employee,
):
self.payroll_calculator = payroll_calculator
self.payroll_transferrer = payroll_transferrer
self.payroll_reporter = payroll_reporter
self.employee = employee
def run(self):
payroll = self.payroll_calculator.calculate_payroll(employee=self.employee)
self.payroll_transferrer.transfer_payroll(payroll=payroll, employee=self.employee)
self.payroll_reporter.report_payroll(payroll=payroll, employee=self.employee)
You may have noticed that PayrollCalculator
is not responsible to one client anymore.
The following diagram shows that it is now responsible to more than one client:
The problem remains if the TaxAdvisor
wants to calculate the payroll excluding health_insurance
.
What if a developer directly modifies PayrollTransferrer.calculate_payroll()
in which it calculates payroll without deducting health_insurance
?
The unwanted impact will reach the HR department and that will tranfer and report wrong payroll.
To address this problem, we should make our clients to interact with an interface not a concrete class. This is known as open-closed principle (OCP) and it often comes along with the SRP. I won’t get into detail as it’s out of the topic, but the idea is to make the class protected from any unwanted change:
In Python, we can implement this interface idea by using abc
(Abstract Base Classes) module:
# payroll.py
from abc import ABC, abstractmethod
from employee import Employee
class IPayrollCalculator(ABC):
@abstractmethod
def calculate_payroll(self, employee: Employee) -> int:
...
class HRPayrollCalculator(IPayrollCalculator):
def calculate_payroll(self, employee: Employee) -> int:
print(f"Calculating payroll of employee {employee.id}")
state_tax = employee.weekly_salary * 0.1
health_insurance = employee.weekly_salary * 0.05
return employee.weekly_salary - state_tax - health_insurance
class TaxAdvisorPayrollCalculator(IPayrollCalculator):
def calculate_payroll(self, employee: Employee) -> int:
print(f"Calculating payroll of employee {employee.id}")
state_tax = employee.weekly_salary * 0.1
return employee.weekly_salary - state_tax
...
As a result, the HR dept and the tax advisor should use different objects particularly implemented for each purpose.
Conclusion
As most software engineering skills do, this principle takes some time and experience to be truly grasped. I, too, have been trying to train myself to understand and apply it to the real-world problems.
Of course, any software failure derived from not conforming to the SOLID principles could be prevented if there were the solid test code that automatically runs before deploying. Nonetheless, this principle is to avoid wasting time trying to understand the code and trying not to make mistakes.