Simplifying Code with the Single Responsibility Principle
The Filing Cabinet That Also Sends Emails
Imagine a filing clerk whose job is: 1.
๐ฏ System Scenario & Requirements
System Goal
Imagine a filing clerk whose job is: 1.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- Imagine a filing clerk whose job is: 1.
- Store documents (filing cabinet logic).
- Send notification emails whenever a document is filed.
Constraints & Assumptions
- SRP is sometimes misunderstood as "one method per class." That's wrong.
- Cohesion is the right mental model: group methods that change together and depend on the same data .
โก Execution Stages & Decision Trace
Stage 1 โ The Classic Violation: User Manager Who Does Everything
Reasons this class must change: - The database schema changes โ modify DB logic.
- The welcome email template changes โ modify email logic.
Deep Dive
Scenario context
Imagine a filing clerk whose job is:
- Store documents (filing cabinet logic).
- Send notification emails whenever a document is filed.
- Log activity to an audit trail.
One person doing three jobs โ fine until they're on vacation and you only need the email changed. You have to find and understand the whole multi-responsibility class just to change one notification template.
SRP says: each clasโฆ
This walkthrough focuses on The Filing Cabinet That Also Sends Emails.
Before making a design choice, clarify:
- Imagine a filing clerk whose job is: 1.
- Store documents (filing cabinet logic).
- Send notification emails whenever a document is filed.
Concepts in play: design-principles-solid, event-driven-architecture, jvm-internals-and-garbage-collection, low-level-design-systems
Stage 1 โ The Classic Violation: User Manager Who Does Everything
- Reasons this class must change: - The database schema changes โ modify DB logic.
- The welcome email template changes โ modify email logic.
- The audit log format changes โ modify logging logic.
- Three reasons to change = three responsibilities.
// โ SRP violation โ two very different reasons to change this class
class UserManager {
public void saveUser(User user) {
// 1. save to DB
db.execute("INSERT INTO users VALUES (?)", user.data());
// 2. send a welcome email
emailService.send(user.email, "Welcome to our platform!", WELCOME_TEMPLATE);
// 3. write audit log
logger.info("User created: " + user.getId());
}
}
classDiagram
class UserManager {
+saveUser()
+sendWelcomeEmail()
+logAuditTrail()
}
note for UserManager "Violates SRP: DB, email, and logging in one class"
Observable outcome: Three reasons to change = three responsibilities.
Stage 2 โ The SRP Fix: One Class, One Job
- Now each class has exactly one reason to change.
- UserRegistrationService orchestrates the flow but owns none of the individual mechanics.
- Notice the key benefit beyond cleanliness: independent deployability of understanding .
- A new developer joining the team can read WelcomeEmailService in thirty seconds and fully understand what it does and what could cause it to change.
// โ
Split into single-responsibility classes
class UserRepository {
public void save(User user) {
db.execute("INSERT INTO users VALUES (?)", user.data());
}
}
class WelcomeEmailService {
public void sendWelcome(User user) {
emailService.send(user.email, "Welcome!", WELCOME_TEMPLATE);
}
}
class UserAuditLogger {
public void logCreated(User user) {
logger.info("User created: " + user.getId());
}
}
// Orchestrator โ knows when to call each, but not how they work
class UserRegistrationService {
private final UserRepository repo;
private final WelcomeEmailService emailSvc;
private final UserAuditLogger audit;
public void register(User user) {
repo.save(user);
emailSvc.sendWelcome(user);
audit.logCreated(user);
}
}
classDiagram
class UserRegistrationService {
+register()
}
class UserRepository {
+save()
}
class WelcomeEmailService {
+sendWelcome()
}
class UserAuditLogger {
+logCreated()
}
UserRegistrationService --> UserRepository
UserRegistrationService --> WelcomeEmailService
UserRegistrationService --> UserAuditLogger
Observable outcome: A new developer joining the team can read WelcomeEmailService in thirty seconds and fully understand what it does and what could cause it to change.
Stage 3 โ Deep Dive: Detecting SRP Violations
Observable outcome: You can explain how โDeep Dive: Detecting SRP Violationsโ changes the architecture or implementation path.
Stage 4 โ SRP Responsibility Flow
- When a request hits UserRegistrationService.register() , responsibility flows cleanly to dedicated single-purpose classes โ each is an isolated change axis.
- If the database schema changes, only UserRepository is modified.
- If the email template changes, only WelcomeEmailService is touched.
- The orchestrator UserRegistrationService never changes for infrastructure reasons โ only if the registration workflow itself changes.
flowchart LR
A["register(user)"] --> B[UserRepository.save]
A --> C[WelcomeEmailService.sendWelcome]
A --> D[UserAuditLogger.logCreated]
B --> E[(Database)]
C --> F[Email Provider]
D --> G[Audit Log]
Observable outcome: The orchestrator UserRegistrationService never changes for infrastructure reasons โ only if the registration workflow itself changes.
Stage 5 โ Real-World Application: SRP at Scale
- SRP applies at every scale โ class, service, and infrastructure.
- Where to apply SRP first: Start at the class level.
- Pain signals: unit tests with 5+ mocks in setUp, class names with "And" or "Manager", files frequently edited by two different teams for unrelated features.
- At the microservice level, SRP manifests as bounded contexts โ each service owns one business capability.
Observable outcome: At the microservice level, SRP manifests as bounded contexts โ each service owns one business capability.
Stage 6 โ Hands-On: Identify and Refactor SRP Violations
- Here is a class that violates SRP.
- Identify the responsibilities, then split them.
- Five responsibilities โ five focused classes: Orchestrator: OrderProcessingService has one responsibility: orchestrating the order workflow.
- Each collaborator has one responsibility: doing its specific job.
// ๐ How many responsibilities does this class have?
class OrderProcessor {
public void processOrder(Order order) {
// Responsibility 1: Validate
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order has no items");
}
// Responsibility 2: Charge
stripeClient.charge(order.getCustomerId(), order.getTotal());
// Responsibility 3: Update inventory
for (Item item : order.getItems()) {
inventoryDb.decrementStock(item.getSku(), item.getQuantity());
}
// Responsibility 4: Notify
emailService.send(order.getEmail(), "Order confirmed", buildBody(order));
// Responsibility 5: Audit
logger.info("Order processed: " + order.getId());
}
}
class OrderProcessingService {
public void processOrder(Order order) {
validator.validate(order);
paymentService.charge(order);
inventoryService.decrementStock(order);
notificationService.sendConfirmation(order);
auditLogger.logProcessed(order);
}
}
Observable outcome: Each collaborator has one responsibility: doing its specific job.
End-to-end outcome
After working through the stages, you should be able to explain:
- The "And" test always works.
- If you cannot name a class without "And" or "Manager", it has multiple responsibilities.
Alternatives considered
Decision Guide
Decision Guide
Failure and degraded modes
Trade-offs & Failure Modes: SRP vs. Cohesion
- Detection: SRP is sometimes misunderstood as "one method per class." That's wrong.
- User or operator impact: SRP is sometimes misunderstood as "one method per class." That's wrong.
