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
User
class doesn’t do its own validation - A
ReportGenerator
shouldn’t know how to email the report - A
DataImporter
shouldn’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:
AuthenticationService
UserRegistrationService
SessionManager
EmailConfirmationService
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).