Simplifying Code with the Single Responsibility Principle
A class should have one, and only one, reason to change. We explain the 'S' in SOLID with a simpl...
Abstract AlgorithmsAI-assisted content. This post may have been written or enhanced with AI tools. Please verify critical information independently.
TLDR
TLDR: The Single Responsibility Principle says a class should have only one reason to change. If a change in DB schema AND a change in email format both require you to edit the same class, that class has two responsibilities โ and needs to be split.
๐ The Filing Cabinet That Also Sends Emails
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 class has one job. One reason to put it in "maintenance mode."
๐ The Classic Violation: User Manager Who Does Everything
// โ 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());
}
}
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: UserManager With Three Reasons to Change
classDiagram
class UserManager {
+saveUser()
+sendWelcomeEmail()
+logAuditTrail()
}
note for UserManager "Violates SRP: DB, email, and logging in one class"
This class diagram exposes the SRP violation at a glance: UserManager holds three unrelated operations โ saveUser (database concern), sendWelcomeEmail (notification concern), and logAuditTrail (observability concern). Each belongs to a different team's domain and a different reason to change. The takeaway is that whenever a class diagram shows methods from clearly different business domains in one box, the class has too many responsibilities.
โ๏ธ The SRP Fix: One Class, One Job
// โ
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);
}
}
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. With the original UserManager, understanding the email logic required reading through unrelated database and logging code first. At scale โ with hundreds of classes, dozens of developers โ that cognitive overhead compounds into real velocity loss.
SRP also makes testing dramatically easier. Each focused class can be unit-tested with a single mock at most. The original UserManager required mocking a database, an email service, and a logger for every test โ even when you only cared about email behavior.
๐ SRP Correct: Each Class Has Its Own Single Reason to Change
classDiagram
class UserRegistrationService {
+register()
}
class UserRepository {
+save()
}
class WelcomeEmailService {
+sendWelcome()
}
class UserAuditLogger {
+logCreated()
}
UserRegistrationService --> UserRepository
UserRegistrationService --> WelcomeEmailService
UserRegistrationService --> UserAuditLogger
This class diagram shows the SRP-correct design: UserRegistrationService is now a thin orchestrator with arrows pointing to three single-purpose collaborators. Each leaf class (UserRepository, WelcomeEmailService, UserAuditLogger) has exactly one reason to change โ a database schema change only touches UserRepository, an email template update only touches WelcomeEmailService. Notice that the arrows represent dependency, not inheritance โ this is the shape of a clean, testable design.
๐ง Deep Dive: Detecting SRP Violations
Common signals:
| Signal | Example |
| Class name contains "And" | FileReaderAndParser, UserManagerAndNotifier |
| More than ~200 lines in a single class | Logic has grown without boundaries |
| Unit test needs many unrelated mocks | new UserManager(mockDB, mockEmailService, mockLogger, mockMetrics, ...) |
| Change in one feature breaks a test for another | Updating DB logic fails email tests |
| Merge conflicts between teammates on the same class | Two teams editing the same file for unrelated features |
โ๏ธ Trade-offs & Failure Modes: SRP vs. Cohesion
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. A User class can have getFullName(), getEmail(), and isActive() โ all relate to the same entity and would change for the same reasons.
| Too granular (over-SRP) | Appropriately SRP | Too coarse |
UserFirstNameGetter, UserLastNameGetter | User (all core user properties) | UserManagerNotifierLogger |
EmailValidator, EmailLengthChecker | EmailValidator (all validation rules) | UserEmailAndPermissionClass |
Rule of thumb: Ask "If the business rule changes for X, what code must change?" Group that code together.
๐ SRP Decision Flow: Does This Class Have One Reason to Change?
flowchart TD
A[Review Class] --> B{One reason to change?}
B -- Yes --> C[Follows SRP]
C --> D[Keep as is]
B -- No --> E[Identify Responsibilities]
E --> F[Extract Each Responsibility]
F --> G[Create Focused Classes]
G --> H[Inject Dependencies]
H --> I[Now follows SRP]
This flowchart gives you a repeatable decision process for any class under review. Start at "Review Class" and ask a single binary question: does it have one reason to change? If yes, nothing needs to happen. If no, the flow guides you through identifying each responsibility, extracting it into a focused class, and injecting dependencies โ the result is a set of classes each pointing to a single reason to change. The key insight is that SRP refactoring always ends with the same structural pattern: one orchestrator and multiple focused collaborators.
๐ SRP Responsibility Flow
When a request hits UserRegistrationService.register(), responsibility flows cleanly to dedicated single-purpose classes โ each is an isolated change axis.
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]
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.
๐ Real-World Application: SRP at Scale
SRP applies at every scale โ class, service, and infrastructure.
| Layer | SRP Violation | SRP-Compliant |
| Class | UserManager (save + email + audit) | UserRepository, EmailService, AuditLogger |
| REST controller | One endpoint handles auth AND business logic | Separate auth middleware, business handler |
| Microservice | OrderService manages checkout, inventory, and shipping | Separate checkout, inventory, shipping services |
| Lambda function | One function parses API events and writes to DB | Parser function โ writer function |
| Database | users table stores both profile data and activity logs | Separate users and user_activity tables |
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. Conway's Law predicts teams build systems that mirror their communication structure, so SRP at the organizational level means SRP in the architecture.
๐งช Hands-On: Identify and Refactor SRP Violations
Here is a class that violates SRP. Identify the responsibilities, then split them.
// ๐ 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());
}
}
Five responsibilities โ five focused classes:
| Original responsibility | New class |
| Validate order | OrderValidator |
| Charge customer | PaymentService |
| Update inventory | InventoryService |
| Send confirmation | OrderNotificationService |
| Write audit | OrderAuditLogger |
Orchestrator:
class OrderProcessingService {
public void processOrder(Order order) {
validator.validate(order);
paymentService.charge(order);
inventoryService.decrementStock(order);
notificationService.sendConfirmation(order);
auditLogger.logProcessed(order);
}
}
OrderProcessingService has one responsibility: orchestrating the order workflow. Each collaborator has one responsibility: doing its specific job.
Decision Guide
| Situation | Recommended Action |
| Class name contains "And" or "Manager" | Split into focused single-purpose classes |
| Unit test setUp requires 5+ unrelated mocks | Refactor the class under test to reduce responsibilities |
| Two teammates edit the same file for unrelated features | Separate those responsibilities into distinct classes |
| Change in one area breaks tests for another | Isolate the two concerns |
| Entity class has 3โ4 cohesive methods | Keep as-is โ cohesion is not an SRP violation |
๐ ๏ธ Spring Framework: How Dependency Injection Makes SRP Unavoidable
Spring Framework is a lightweight Java application framework built around dependency injection (DI) and inversion of control. It is the dominant enterprise Java framework, used across millions of Spring Boot production systems worldwide.
When you split a bloated UserManager into UserRepository, WelcomeEmailService, and UserAuditLogger, Spring's DI container wires them together automatically at startup. Each @Service class declares its single responsibility by name, and constructor injection makes every collaborator explicit โ no hidden new calls, no tangled constructors.
// โ
Spring DI makes each responsibility a named, independently testable bean
@Service
@RequiredArgsConstructor // Lombok generates constructor injection
public class UserRepository {
private final JdbcTemplate jdbc;
public void save(User user) {
jdbc.update("INSERT INTO users (id, email) VALUES (?, ?)",
user.getId(), user.getEmail());
}
}
@Service
@RequiredArgsConstructor
public class WelcomeEmailService {
private final JavaMailSender mailer;
public void sendWelcome(User user) {
SimpleMailMessage msg = new SimpleMailMessage();
msg.setTo(user.getEmail());
msg.setSubject("Welcome to Abstract Algorithms!");
mailer.send(msg);
}
}
@Service
@RequiredArgsConstructor
public class UserRegistrationService { // orchestrator โ one responsibility
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);
}
}
Unit-testing WelcomeEmailService now requires mocking only JavaMailSender. The test setUp is five lines, not fifty. Spring's @MockBean replaces any collaborator in integration tests without touching the others โ the payoff of SRP is felt directly at the test layer, where mocking pressure drops to zero.
For a full deep-dive on Spring Framework dependency injection and bean lifecycle management, a dedicated follow-up post is planned.
๐ Lessons Learned From SRP in Practice
- The "And" test always works. If you cannot name a class without "And" or "Manager", it has multiple responsibilities.
UserManagerAndNotifiershould be two classes. - Merge conflicts signal SRP violations. When two teammates routinely edit the same file for unrelated features, that file is a shared liability โ split it.
- SRP โ small classes. A 300-line
BillingCalculatorcan be SRP-compliant if all its methods change for the same reason. A 40-line class can violate SRP if it mixes two unrelated concerns. - Orchestrators are not violations.
UserRegistrationServicecallingUserRepository,EmailService, andAuditLoggeris an orchestrator with one responsibility: coordinating registration. It does not implement any of the individual operations. - Refactor when it hurts, not preemptively. Wait until you feel real pain (slow tests, confusing code, constant merge conflicts) before splitting. Premature SRP splits add complexity without payoff.
- SRP and DIP work together. Once you split a class into focused pieces, injecting them as interfaces (DIP) makes the system testable and flexible. SRP defines the boundaries; DIP keeps them loosely coupled.
๐ TLDR: Summary & Key Takeaways
- A class should have one reason to change โ one axis of responsibility.
- The "And" smell in class names, oversized test setUp, and frequent merge conflicts all signal SRP violations.
- Split responsibilities into focused classes; use an orchestrator to compose them.
- Don't over-SRP: cohesion means grouping code that changes together โ not one method per class.
- Applied consistently, SRP reduces cognitive overhead, speeds up onboarding, and makes every unit test narrower and faster to write.
๐ Related Posts
- Open/Closed Principle: Extend Without Modifying
- Interface Segregation Principle: No Fat Interfaces
- Dependency Inversion Principle: Decoupling Your Code
- KISS, YAGNI, and DRY Principles Explained
Test Your Knowledge
Ready to test what you just learned?
AI will generate 4 questions based on this article's content.

Written by
Abstract Algorithms
@abstractalgorithms
More Posts
RAG vs Fine-Tuning: When to Use Each (and When to Combine Them)
TLDR: RAG gives LLMs access to current knowledge at inference time; fine-tuning changes how they reason and write. Use RAG when your data changes. Use fine-tuning when you need consistent style, tone, or domain reasoning. Use both for production assi...
Fine-Tuning LLMs with LoRA and QLoRA: A Practical Deep-Dive
TLDR: LoRA freezes the base model and trains two tiny matrices per layer โ 0.1 % of parameters, 70 % less GPU memory, near-identical quality. QLoRA adds 4-bit NF4 quantization of the frozen base, enabling 70B fine-tuning on 2ร A100 80 GB instead of 8...
Build vs Buy: Deploying Your Own LLM vs Using ChatGPT, Gemini, and Claude APIs
TLDR: Use the API until you hit $10K/month or a hard data privacy requirement. Then add a semantic cache. Then evaluate hybrid routing. Self-hosting full model serving is only cost-effective at > 50M tokens/day with a dedicated MLOps team. The build ...
Watermarking and Late Data Handling in Spark Structured Streaming
TLDR: A watermark tells Spark Structured Streaming: "I will accept events up to N minutes late, and then I am done waiting." Spark tracks the maximum event time seen per partition, takes the global minimum across all partitions, subtracts the thresho...
