Software teams love principles. But somewhere along the way, many Java codebases started using the Single Responsibility Principle (SRP) as a mechanical refactoring rule: split every class until it does “just one thing.” The result is often an explosion of tiny classes that add ceremony without clarity, and indirection without intention.
This article makes a practical case: stop treating SRP as a refactoring checklist. Treat it as a design lens that helps you organize code around cohesive reasons to change.
“A module should have one reason to change.” — Robert C. Martin, The Single Responsibility Principle
What SRP Actually Says
SRP is not “a class should only have one public method” or “every if-statement deserves a new class.” It’s a principle of cohesion: group code so that when a stimulus in your system’s world changes, you don’t have to edit code scattered across the codebase.
- Responsibility means “reason to change,” not “one task.”
- A class can contain multiple operations if they change together.
- A class should not mix things that change for different reasons.
This matters because change is the primary cost-driver in software. SRP aims to localize change. If a team changes tax rules, you shouldn’t touch your email notification code. If you swap your persistence technology, you shouldn’t edit domain rules.
See also:
- Cohesion (software)
- Shotgun Surgery and Divergent Change from Martin Fowler
Why SRP Is Not a Refactoring Recipe
Refactoring is a disciplined technique for improving design while preserving behavior. SRP helps choose a direction, but it’s not a step-by-step recipe. Misapplying SRP mechanically can make things worse:
- Class explosion: Dozens of “single-method” classes obscure the domain.
- Indirection inflation: More layers, more wiring, little gain in locality.
- Mock hell: Over-sliced code pushes you into mocking internal details for tests.
- Packaging lies: Artificial components that look separate but always change together.
SRP is most valuable when you can articulate the real “actors” and change forces:
- Business policy (“How do we compute tax?”)
- Environment or technology (“How do we persist data?”)
- External integration (“How do we notify customers?”)
If these forces evolve independently, split along those seams. If they move together, keep them together.
A Naive “Do Everything” Java Service
Let’s start with a simple, cohesive-but-messy service. It validates an order, computes price and tax, saves to disk, sends a notification, and writes a “PDF” (simulated). It’s not production-grade, but it compiles and runs.
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.nio.file.*;
import java.io.IOException;
import java.io.UncheckedIOException;
public class OrderService {
public void place(Order order) {
// Validate
if (order.getLines().isEmpty()) {
throw new IllegalArgumentException("Order must have at least one line");
}
if (order.getEmail() == null || order.getEmail().isBlank()) {
throw new IllegalArgumentException("Email is required");
}
// Price and tax
BigDecimal subtotal = order.getLines().stream()
.map(l -> l.getUnitPrice().multiply(BigDecimal.valueOf(l.getQty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal tax = subtotal.multiply(new BigDecimal("0.20")); // 20% VAT for demo
BigDecimal total = subtotal.add(tax);
order.setTotal(total);
// "Persist"
try {
String record = "ORDER|" + order.getEmail() + "|" + order.getTotal() + System.lineSeparator();
Files.writeString(Paths.get("orders.log"), record, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
// Notify
System.out.println("Sending confirmation to " + order.getEmail() + " with total " + order.getTotal());
// "PDF"
try {
Files.writeString(Paths.get("invoice-" + System.currentTimeMillis() + ".txt"),
"Invoice for " + order.getEmail() + " total: " + order.getTotal());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static class Order {
private final String email;
private final List lines = new ArrayList<>();
private BigDecimal total = BigDecimal.ZERO;
public Order(String email) {
this.email = email;
}
public void addLine(OrderLine line) { lines.add(line); }
public List getLines() { return lines; }
public String getEmail() { return email; }
public BigDecimal getTotal() { return total; }
public void setTotal(BigDecimal total) { this.total = total; }
}
public static class OrderLine {
private final String sku;
private final int qty;
private final BigDecimal unitPrice;
public OrderLine(String sku, int qty, BigDecimal unitPrice) {
this.sku = sku;
this.qty = qty;
this.unitPrice = unitPrice;
}
public String getSku() { return sku; }
public int getQty() { return qty; }
public BigDecimal getUnitPrice() { return unitPrice; }
}
}
What’s wrong? It mixes business policy (tax), environment details (file system), and integration (notification). Changes in any category force edits in one class. That’s the classic SRP smell.
The Over-Refactoring Trap
A common reaction: split everything into a tiny class. The code “looks” SRP-compliant but still changes together. Worse, tests now mock mundane details.
import java.math.BigDecimal;
import java.nio.file.*;
import java.io.IOException;
import java.io.UncheckedIOException;
class OrderValidator {
void validate(OrderService.Order order) {
if (order.getLines().isEmpty())
throw new IllegalArgumentException("Order must have at least one line");
if (order.getEmail() == null || order.getEmail().isBlank())
throw new IllegalArgumentException("Email is required");
}
}
class OrderPricer {
BigDecimal totalWithTax(OrderService.Order order) {
BigDecimal subtotal = order.getLines().stream()
.map(l -> l.getUnitPrice().multiply(BigDecimal.valueOf(l.getQty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
return subtotal.add(subtotal.multiply(new BigDecimal("0.20")));
}
}
class OrderPersister {
void save(OrderService.Order order) {
try {
String record = "ORDER|" + order.getEmail() + "|" + order.getTotal() + System.lineSeparator();
Files.writeString(Paths.get("orders.log"), record, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
class OrderNotifier {
void send(OrderService.Order order) {
System.out.println("Sending confirmation to " + order.getEmail() + " with total " + order.getTotal());
}
}
class OrderInvoiceGenerator {
void generate(OrderService.Order order) {
try {
Files.writeString(Paths.get("invoice-" + System.currentTimeMillis() + ".txt"),
"Invoice for " + order.getEmail() + " total: " + order.getTotal());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
This appears tidy but misses the point:
- All these pieces still change together when tax changes (tests, invoices, etc.), or when IO rules change.
- You’ve added indirection and classes, but not meaningful separation of change reasons.
SRP is not satisfied by moving code into smaller classes. It’s satisfied by creating components aligned with distinct change forces and boundaries.
A Better Approach: Separate by Change Reason
Instead of carving classes by verb, identify the actors and forces:
- Business policy: Tax calculation varies by jurisdiction and business rules.
- Persistence: Will we move from file to database or an API?
- Notification: Email now; SMS tomorrow?
We can use a ports-and-adapters (hexagonal) style to isolate these forces.
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.nio.file.*;
import java.io.IOException;
import java.io.UncheckedIOException;
// Domain model
class Order {
private final String email;
private final List lines = new ArrayList<>();
private BigDecimal total = BigDecimal.ZERO;
Order(String email) { this.email = email; }
void addLine(OrderLine line) { lines.add(line); }
List lines() { return lines; }
String email() { return email; }
BigDecimal total() { return total; }
void total(BigDecimal total) { this.total = total; }
}
class OrderLine {
private final String sku;
private final int qty;
private final BigDecimal unitPrice;
OrderLine(String sku, int qty, BigDecimal unitPrice) {
this.sku = sku; this.qty = qty; this.unitPrice = unitPrice;
}
int qty() { return qty; }
BigDecimal unitPrice() { return unitPrice; }
}
// Policies (business)
interface TaxPolicy {
BigDecimal taxOn(BigDecimal subtotal, Order order);
}
class DefaultTaxPolicy implements TaxPolicy {
public BigDecimal taxOn(BigDecimal subtotal, Order order) {
return subtotal.multiply(new BigDecimal("0.20")); // policy; can swap per region
}
}
// Ports (technology boundaries)
interface OrderRepository {
void save(Order order);
}
class FileOrderRepository implements OrderRepository {
public void save(Order order) {
try {
String record = "ORDER|" + order.email() + "|" + order.total() + System.lineSeparator();
Files.writeString(Paths.get("orders.log"), record, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
interface Notifier {
void orderConfirmed(String email, BigDecimal total);
}
class ConsoleNotifier implements Notifier {
public void orderConfirmed(String email, BigDecimal total) {
System.out.println("Sending confirmation to " + email + " with total " + total);
}
}
// Application service (orchestrator)
class OrderServiceBetter {
private final TaxPolicy taxPolicy;
private final OrderRepository repository;
private final Notifier notifier;
OrderServiceBetter(TaxPolicy taxPolicy, OrderRepository repository, Notifier notifier) {
this.taxPolicy = taxPolicy;
this.repository = repository;
this.notifier = notifier;
}
public void place(Order order) {
validate(order);
BigDecimal subtotal = order.lines().stream()
.map(l -> l.unitPrice().multiply(BigDecimal.valueOf(l.qty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal total = subtotal.add(taxPolicy.taxOn(subtotal, order));
order.total(total);
repository.save(order);
notifier.orderConfirmed(order.email(), order.total());
}
private void validate(Order order) {
if (order.lines().isEmpty()) throw new IllegalArgumentException("Order must have at least one line");
if (order.email() == null || order.email().isBlank()) throw new IllegalArgumentException("Email is required");
}
}
What’s different and why it’s better:
- TaxPolicy isolates business variability. New tax rules? Swap or extend a policy. No changes to persistence or notification.
- OrderRepository isolates tech choices. Move from file to JDBC or JPA? Swap the adapter, not the domain or application service.
- Notifier isolates communication channels. Add SMS later without touching pricing.
We’ve not created microunits of work; we’ve created seams aligned with independent reasons to change.
Packaging for Signal, Not Noise
A helpful package layout emphasizes these seams:
com.example.orders
domain/
Order.java
OrderLine.java
TaxPolicy.java
DefaultTaxPolicy.java
app/
OrderService.java
adapters/
persistence/
OrderRepository.java
FileOrderRepository.java
JpaOrderRepository.java
notification/
Notifier.java
ConsoleNotifier.java
SmtpNotifier.java
- domain: business concepts and policies
- app: orchestration/use cases
- adapters: technology specifics
This structure communicates change boundaries clearly to your team.
Heuristics: When SRP Suggests “Merge,” Not “Split”
SRP can justify merging code as much as splitting it. Use these heuristics:
- Change-together rule: If two pieces always evolve together, co-locate them.
- High cohesion, low ceremony: Prefer a clear, cohesive class over five pass-through wrappers.
- Test friction: If adding a feature requires editing many tiny classes and complex test doubles, you likely over-sliced.
- Boundaries ≠ verbs: Create boundaries around actors and change forces (policy vs technology), not around every verb.
- Data and behavior travel together: If behavior uses the same data shape intensively, keep them close unless a clear boundary exists.
- Follow the history: Use version control to observe change patterns; split where churn diverges.
SRP, Refactoring, and Architecture
Refactoring is how you get from “today’s shape” to “tomorrow’s design.” SRP helps you choose target shapes:
- Start simple, make it work, then look for true seams.
- Extract interfaces at boundaries where substitution is likely (persistence, integrations).
- Avoid speculative separation: do not introduce “layers” without a real actor or change reason.
- Defer micro-splitting until the boundaries pay off in testing or feature cadence.
For more, see Refactoring and the pragmatic advice around smells like Divergent Change and Shotgun Surgery.
Common Anti-Patterns to Avoid
- “Class-per-if”: Extracting tiny classes for each condition without meaningful abstraction.
- “ManagerFactoryHelper”: Vague names signal weak boundaries; be specific to the change force.
- “Anemic adapters”: Pass-through classes that only forward calls; prefer direct usage until you need a boundary.
- “God packages”: Packages named util, common, base; they undermine SRP at the module level.
Practical Workflow
- Implement the feature straightforwardly with clear naming.
- Ask: what are the likely independent reasons to change? Policies, tech, integrations?
- Extract boundaries where substitution is probable or testing becomes easier.
- Use constructor injection to express dependencies explicitly.
- Keep cohesive things together; don’t split for the sake of splitting.
- Revisit boundaries as the system evolves; merge when over-abstracted, split when churn diverges.
Conclusion
SRP is not a “split-until-single-method” mandate. It’s a design principle that asks you to align code with real change forces. In Java, that often means clear domain policies, application orchestration, and adapter boundaries—not a sea of tiny classes that orbit a single method.
Use SRP as a lens. Let actual change, not aesthetics, drive your refactorings. The result: code that is easier to reason about, safer to modify, and faster to evolve.
Further reading: