SRP is the first of the SOLID principles, and it sounds deceptively simple:
A class should have only one reason to change
One. Not âalso sends emailsâ or âoccasionally dips into database logic when boredâ!
If a class has more than one reason to change, itâs doing too much. For example, a Logger class shouldnât wake up one day and decide to start handling file compression.
The twist?
Itâs not always obvious what counts as one job.
Is formatting logs part of logging? Is validating input part of registering a user?
Is existential crisis part of being a DevOps engineer? (Okay, maybe that oneâs fair.)
Applying SRP takes experience and context. Itâs less âfollow this ruleâ and more âdonât build a Swiss Army knife when a screwdriver will doâ
Letâs see what happens when SRP goes out the windowâand how to clean up the mess.
Example of SRP violation
class DataProcessor: def __init__(self, slack_client: SlackClient): self.slack_client = slack_client
def process_and_notify(self) -> None: processed_data = self._process_data() self._notify_via_slack(processed_data, channel='general')
def _process_data(self, data): # Process data return data
def _notify_via_slack(self, data, channel = 'general') -> None: self.slack_client.send_message(self._format_data_for_stack(data))Clearly, this violates SRP because not only we are processing data, but we are also sending notifications via Slack. This class has two reasons to change: if the data processing logic changes or if the notification logic changes.
To fix this, we should split the class into two classes: one for data processing and another for sending notifications.
class DataProcessor: def __init__(self): pass
def process_data(self, data): # Process data return data
class SlackNotifier: def __init__(self, slack_client: SlackClient): self.slack_client = slack_client
def notify(self, data, channel = 'general') -> None: self.slack_client.send_message(self._format_data_for_stack(data))Now we can use a composition to use both classes together:
class DataProcessorAndNotifier: def __init__(self, data_processor: DataProcessor, slack_notifier: SlackNotifier): self.data_processor = data_processor self.slack_notifier = slack_notifier
def process_and_notify(self, data) -> None: processed_data = self.data_processor.process_data(data) self.slack_notifier.notify(processed_data)But wouldnât it mean the DataProcessorAndNotifier class has two reasons to change?
Yes, it does. But the difference is that the reasons to change are in separate classes.
This way, if we need to change the data processing logic, we only need to change the DataProcessor class.
If we need to change the notification logic, we only need to change the SlackNotifier class. This way, we are following the SRP.
On top of this, we can also use the DataProcessor and SlackNotifier classes independently, which is a good thing.
We can use the DataProcessor class in other parts of the system that require data processing, and we can use the SlackNotifier class in other parts of the system that require sending notifications via Slack.
They can be used separately, maintained separately, and tested separately.
Letâs consider a slightly more subtle example.
SRP Violation That Pretends to Be Fine
class UserService: def register_user(self, username, password): # Business logic to register a user if not username or not password: raise ValueError("Username and password are required")
user = {'username': username, 'password': password}
# Subtle violation: Persistence logic self._save_to_database(user) return user
def _save_to_database(self, user): # Simulate saving to database print(f"User {user['username']} saved to the database")This seems quite logical, isnât it?
Itâs a fairly simple class that seemingly does one thing, or at least the public interface says so, as we only have UserService::register publicly abailable.
But it violates SRP because it does two things:
- input validation
- persisting user to the database
If we need to change input validation or database persistence, we need to change this class.
Subtle violations are sort of fine. At least it wonât take us long to refactor the code.
Where SRP violation really goes bad is when it goes too far.
God objects
In the extreme case of SRP violation we can get a God object, a class that is overloaded with too many responsibilities. Throughout my career Iâve seen many of these, and they are usually the result of a lack of understanding of the domain, lack of experience, lack of time for refactoring, lack of refactoring skills, simple lazyness, or a combination of these The problem with God objects highlights why SRP is important: when code violates this simple principle, itâs hard to maintain, hard to test, hard to understand, and hard to refactor (as a result of being hard to test).
In the following example a lazy developer instead of properly planning and designing the system, decided to have an âumbrellaâ class that contains all the logic of the system that doesnât fit anywhere else (or so they thought).
class ApplicationManager: def __init__(self): self.config = {} self.users = [] self.logs = [] self.database = None self.current_user = None
# Configuration management def load_configuration(self, config_file): with open(config_file, 'r') as file: self.config = file.read()
def save_configuration(self, config_file): with open(config_file, 'w') as file: file.write(self.config)
# User management def add_user(self, username, password): self.users.append({'username': username, 'password': password})
def remove_user(self, username): self.users = [user for user in self.users if user['username'] != username]
def authenticate_user(self, username, password): user = next((user for user in self.users if user['username'] == username and user['password'] == password), None) if user: self.current_user = user return True return False
# Logging def log(self, message): self.logs.append(f"{self.current_user['username']}: {message}")
def flush_logs(self): for log in self.logs: print(log)
# Database management def connect_to_database(self, db_string): self.database = f"Connected to {db_string}"
def execute_query(self, query): if self.database: print(f"Executing query: {query}") else: print("No database connected")
# Application control def start_application(self): print("Application started") self.log("Application started")
def stop_application(self): print("Application stopped") self.log("Application stopped")Yes, it does look ridiculous. You think something like this canât ever be found in the wild? Haha. Rookie.
Config management, user management, logging, database operations, even starting and stopping the app. This is less a class and more an entire backend crammed into a single file.
Now⊠As we finally established some sort of baseline, letâs see what can we do about it,
What is a âresponsibilityâ?
The real reason SRP is tricky isnât because developers donât know how to split classes. Itâs because the word âresponsibilityâ is fuzzy. Itâs not a method count. Itâs not whether your class is under 100 lines. Itâs about the reason your class would change.
A responsibility is a cohesive set of behaviors that change for the same reason. For example:
- Logging is a responsibility
- Persisting to a database is a responsibility
- Sending notifications is a responsibility
- And yes, sometimes even formatting data is a separate responsibility
Sometimes? Yes, SOMETIMES. Very scientific stuff, I know.
Same goes for database operations. Sometimes you can say âI have a repository thatâs only responsibility is to use database as a persistence layer for objects of type Xâ, and sometimes you can split out reads and writes.
At some point you will find what breaks SRP in your system and how to fix it, but at least we should start with conceptual idea of what is responsibility and reason to change for every single class we write.
If your class needs to change when logging changes and when database logic changes â congrats, youâve found the case.
How to actually apply SRP
Theory is cute. But hereâs what applying SRP looks like in practice:
- Group by reason to change, not by type
Donât group code just because it feels similar â group it because it changes together. That means sometimes (yeah, I know, I keep giving myself wiggle room xD):
- A
Userclass doesnât do its own validation - A
ReportGeneratorshouldnât know how to email the report - A
DataImportershouldnât throw in logging âjust in caseâ
If two pieces of logic are maintained by different people or evolve for different business reasons, they donât belong together.
- Think in roles, not objects
Ask: what roles are being played here?
If your UserService is handling login, signup, session management, and email verification â itâs playing too many roles. Break it down:
AuthenticationServiceUserRegistrationServiceSessionManagerEmailConfirmationService
Each one changes for a different reason. Keep them focused.
- Donât fear small classes
Some developers think SRP leads to a million tiny classes.
Me whispering: thatâs fine đ
Big classes are easy to write and hell to maintain. Small, focused classes make your system flexible, composable, and testable. If your IDE has a problem with it, maybe your IDE needs to grow up. Or use vim
- Donât Fear Large Classes
Yes, this sounds like it contradicts #3. It doesnât.
A class with 500 lines isnât inherently a violation of SRP. If those 500 lines exist for one reason â say, rendering Markdown in all its glorious edge casesâthatâs perfectly valid. You donât split something just to make it smaller, you split it because itâs doing too much.
SRP isnât about how much your class does â itâs about how many things itâs responsible for. A large class with one responsibility is fine. A small class with six sneaky ones is not.
When to bend the rule
Like all principles, SRP isnât dogma. Itâs guidance. Youâre allowed to break it if you understand what youâre doing and youâre ready to own the consequences.
If you follow chess scene, you will find grandmasters always break the rules which are mandatory for novice players. They do not develop the pieces, they move the same piece twice, they keep the king in the centre, but this is fine for as long as they have a clear idea WHY in that concrete situation a rule can be broken.
In other words, if you have enough experience and your wisdom tells you to cut corners, maybe it is the right move.
Some cases when itâs fine to break SRP even if you arenât quite grandmaster yet:
- Prototypes where velocity matters more than cleanliness
- Low-impact domains where business logic rarely changes
- Tightly bound functionality that truly evolves together
Just know that youâre creating tech debt on purpose. Donât lie to yourself about it.
TL;DR
If your class doesnât follow SRP, you will suffer.
SRP isnât about number of methods. Itâs about why something changes.
Break responsibilities into roles. Break roles into services. Use composition.
Donât cling to one mega-class to âjust get things doneâ. Youâre building a maintenance nightmare in slow motion.
Use SRP and your future self (and your teammates) will thank you (sometimes).