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:

order model

  • 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:

Refunder and Exchanger

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:

UML of PayrollTransferrer and Employee

# 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.

An HR department uses PayrollTransferrer

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:

A tax advisor uses calculate_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:

PayrollSystem

# 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:

Two clients depend on PayrollCalculator

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:

IPayrollCalculator

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.