Single Responsibility Principle
Overview
The Single Responsibility Principle (`SRP`) states that a class or module should have one, and only one, reason to change. For example, an `OrderValidator` class should check if data is correct, but should not save the order to a database or send an email. Think of it like a restaurant kitchen: the chef cooks, the waiter serves, and the dishwasher cleans. If the chef has to wash dishes, cooking stops when the sink breaks. In Python, violating `SRP` often leads to `God objects`—massive, brittle classes that handle too many concerns at once. For example, a `GodManager` class might include: - `parse_request()`: Parses incoming HTTP data into a dictionary, returns a `dict`, and raises a `ValueError` if the payload is malformed. - `save_to_db()`: Connects to the database and inserts the record, returns `None`, and raises a `ConnectionError` on network failure. - `send_email()`: Triggers a welcome message, returns a `bool` indicating success, and raises a `TimeoutError` if the server is unreachable. One common sharp edge is the trap of `Anemic Domain Models`, where responsibilities are over-separated. class User: def __init__(self, email): self.email = email def update_email(user, new_email): user.email = new_email When data and its intrinsically linked behavior are torn apart, simple state changes require coordinating multiple classes. A class should still own the simple behaviors that mutate its internal state. Another sharp edge is `Constructor Over-Injection`, where a class takes too many dependencies. class OrderProcessor: def __init__(self, db, logger, emailer, config): self.db = db self.logger = logger If a class needs four different services just to instantiate, it is likely doing too much. Splitting the class into smaller, coordinated services fixes this `code smell`.
It isolates changes. When a requirement shifts (e.g., switching from a local database to a cloud API), you only update one focused class without risking bugs in unrelated logic. This prevents `brittle` code where fixing one bug accidentally breaks another part of the system.
Where used: FastAPI routers, Django views, Pydantic validators
Why learn this
- Makes code easier to test (focused classes require fewer mocks)
- Prevents merge conflicts in large teams by keeping files small and specific
- Reduces cognitive load because each class does exactly one thing
Code walkthrough
class Order:
def __init__(self, amount):
self.amount = amount
class EmailService:
def send_receipt(self, order):
print(f'Sending receipt for ${order.amount}')
order = Order(150)
mailer = EmailService()
mailer.send_receipt(order)
Focus: mailer.send_receipt(order)
Aha moment
class ReportParser:
def __init__(self, raw_data):
self.raw_data = raw_data
self.parsed = None
def parse(self):
self.parsed = {'status': self.raw_data.strip()}
print(f'Parsed: {self.parsed}')
def save_to_disk(self):
print('Writing to local disk...')
report = ReportParser(' SUCCESS ')
report.parse()
report.save_to_disk()
Prediction: If the business decides to save reports to an S3 bucket instead of local disk, which class must change?
Common guess: The `ReportParser` class, we just change `save_to_disk` to `save_to_s3`.
Modifying `ReportParser` for storage changes violates `SRP`. `ReportParser` should only parse data. A separate `StorageService` should handle the saving mechanism.
Common mistakes
- The God Object: Creating a single `Manager` or `Handler` class that tries to do everything at once. This often includes: - `connect_db()`: Manages database connections, returns a `Connection` object, and raises `DBError` if it fails. - `process_logic()`: Executes the core business logic, returns a `dict` of results, and raises `ValueError` on bad inputs. - `format_response()`: Formats data into a JSON string, returns a `str`, and raises `TypeError` if serialization fails. Fix: Split these concerns into smaller, focused classes like `DBGateway`, `OrderProcessor`, and `ResponseFormatter`.
- Mixing I/O with Logic: Putting network requests or file I/O inside a core calculation function. Fix: pass the fetched data into a pure calculation function, separating data retrieval from data processing. This makes the core logic easily testable without requiring complex `mock` setups.
Glossary
- God objects
- Massive, overly complex classes in a program that try to do too many different things at once. For example, a single manager class that handles saving, emailing, and reporting. Example: `class GodManager:`.
- brittle
- Code that easily breaks or causes unexpected errors when you try to make small changes or updates. For example, brittle code means fixing one bug accidentally breaks another part of the system. Example: `process_and_validate()`.
Recall questions
- What is the core definition of the Single Responsibility Principle?
- Why does mixing database logic with validation logic violate `SRP`?
- How does `SRP` help when testing code?
- What is the `Constructor Over-Injection` code smell?
Understanding checks
Why does a `God object` violate the Single Responsibility Principle?
A `God object` takes on too many responsibilities (like network, database, and logic) so it has multiple reasons to change.
`SRP` requires a class to have only one reason to change, isolating the changes and preventing unrelated bugs.
Does this code violate `SRP`?
Yes, because it mixes core authentication logic with database querying.
Mixing database access with core business logic means the class has multiple reasons to change.
Practice tasks
Refactor the User Manager
The `UserManager` class currently handles both storing user data and saving it to a database. Refactor it into two classes: `User` to hold the data and `UserRepository` to handle the database simulation.
Challenge
Separate Logging from Execution
You have a `PaymentProcessor` that processes a payment and logs the result directly to the console. Refactor this to separate the logging responsibility into a `ConsoleLogger` class, and pass the logger to the processor's `__init__` method. The processor should use the logger to output the message.
Continue learning
Previous: Class Creation and Instantiation
Previous: Defining functions and return values