Single Responsibility Principle (SRP) in Python

If you need a refresher on Object-Oriented Programming before reading this article, here is all you need:


The Single Responsibility Principle (SRP) is about making a class focus on its primary responsibility.

Other responsibilities must be avoided.

Letting your objects take too many responsibilities is the formula for future headaches and a bunch of code smells.

This can be better explained with code, so let’s see an example of this principle.

Python Code Example

Consider a class called Vehicle like the one below.

We can initialize a vehicle with some attributes like model and year.

We also have some methods like move and accelerate, which are actions a vehicle does.

We also have a __str__(self) to make it easy to print the object in a human-readable format.

class Vehicle:
    def __init__(self, year, model, plate_number, current_speed = 0):
        self.year = year
        self.model = model
        self.plate_number = plate_number
        self.current_speed = current_speed

    def move(self):
        self.current_speed += 1

    def accelerate(self, value):
        self.current_speed += value

    def stop(self):
        self.current_speed = 0

    def __str__(self):
        return f'{self.model}-{self.year}-{self.plate_number}'

my_car = Vehicle(2009, 'F8', 'ABC1234', 100)

my_car.move()

print(my_car.current_speed)

my_car.accelerate(10)

print(my_car.current_speed)

my_car.stop()

print(my_car)

The output of the test above will be:

101
111
F8-2009-ABC1234

The class above follows the Single Responsibility Principle.

It only handles attributes and methods that concern itself, a Vehicle.

Breaking the Single Responsibility Principle

Let’s break the SRP.

Say you want to save the object in a file, to store the information in a persistent way.

Naively, a programmer can just add a save(self, filename) method.

This method will take the object it belongs to and save it in a file.

class Vehicle:
    def __init__(self, year, model, plate_number, current_speed = 0):
        self.year = year
        self.model = model
        self.plate_number = plate_number
        self.current_speed = current_speed

    def move(self):
        self.current_speed += 1

    def accelerate(self, value):
        self.current_speed += value

    def stop(self):
        self.current_speed = 0

    def __str__(self):
        return f'{self.model}-{self.year}-{self.plate_number}'

    def save(self, filename):
        file = open(filename, "w")
        file.write(str(self))
        file.close()

my_car = Vehicle(2009, 'F8', 'ABC1234', 100)

print(my_car)

my_car.save("my_car.txt")

with open("my_car.txt") as f:
    print(f.read())

The output for the code above is:

F8-2009-ABC1234
F8-2009-ABC1234

You can test the code and check it works.

But should a Vehicle class be able to write data to a file?

What does storing info have to do with a Vehicle?

Think about it in terms of a massive system with hundreds or thousands of classes.

Are you going to write a "save file" method for each class?

What happens when you need to make a change to the way your files are stored?

Maybe you want to check whether the path of the file exists to avoid errors and print a message for the user.

In this case, you would need to change every single file containing the "save file" method, which is prone to errors and it is bad practice.

How do we solve this then?

Fixing the class

The fixing, in this case, is as in the code below.

I created a new class called DataService and moved the save method from Vehicle to DataService.

Since DataService is a utility class meant to only save objects to a file, it doesn’t make sense to save itself.

So I annotated the save method with @staticmethod.

If you execute the code below you will notice the behavior is the same and the code still runs.

The difference is that now I can use DataService and save(my_object, filename) to store any kind of object.

And if I want to change the way I save my objects from files to a database, for instance, I just need to make a change in a single place.

Later I cans also implement methods to retrieve the data, update the data, or delete it, among other actions related to data management that are very common in any real-world system.

class Vehicle:
    def __init__(self, year, model, plate_number, current_speed = 0):
        self.year = year
        self.model = model
        self.plate_number = plate_number
        self.current_speed = current_speed

    def move(self):
        self.current_speed += 1

    def accelerate(self, value):
        self.current_speed += value

    def stop(self):
        self.current_speed = 0

    def __str__(self):
        return f'{self.model}-{self.year}-{self.plate_number}'

class DataService:
    @staticmethod
    def save(my_object, filename):
        file = open(filename, "w")
        file.write(str(my_object))
        file.close()

my_car = Vehicle(2009, 'F8', 'ABC1234', 100)

print(my_car)

data_service = DataService()
data_service.save(my_car, "my_car.txt")

with open("my_car.txt") as f:
    print(f.read())

The output will be:

F8-2009-ABC1234
F8-2009-ABC1234

Anti-Pattern: God Classes (God Object)

For every pattern, there is an anti-pattern.

Classes with loads of responsibility are called God Classes.

God is omnipotent, omnipresent, and omniscient, and so it is a God Object.

It is everywhere, it can do everything and it knows everything.

This creates massive classes with thousands of lines of code that no one wants to touch in fear of breaking something.

Keep your classes cohesive, focus on their primary responsibility and avoid this bad practice.

Your future self (and your workmates) will thank you.