Finance asks you to change the tax rate from 18% to 12%. You open OrderProcessor, edit one BigDecimal, and now the diff sits in the same file as the Stripe key lookup, the SQL insert, and the SMTP send. Three teams review a one-line change, and the test that covers it needs a database.
That is a Single Responsibility problem, and it has nothing to do with how many methods the class has.
SRP says a class should have one reason to change — one audience whose decisions force you to edit it. Count audiences, not lines. If you need the one-sentence version of the other four letters, SOLID Roadmap is the glossary; this post stays on S.
The two misreadings
Almost everyone meets SRP as “a class should do one thing,” which is vague enough to justify anything. Two failure modes come out of it:
- “One thing” means one method. Teams shred a cohesive service into
ValidateOrderCommand,CalculateTaxCommand,ChargeCardCommand, and a coordinator that reads like an assembly manifest. The flow becomes unreadable and nothing got cheaper to change. - “One thing” means one noun. Everything about an order belongs in the order class — including the SQL, the email template, and the audit format. That is how a god class is born, one reasonable-sounding commit at a time.
The definition that actually cuts is the one Robert C. Martin later sharpened: a module should be responsible to one actor — one person or group who can request a change. Two actors with edit power over the same file means two reasons to change, and every change risks the other actor’s behavior. Size is not the metric: a 200-line class only finance can change is fine, while a 30-line class that finance, the DBA, and marketing all edit is not.
Count the audiences
Here is the god method from Part 1, compressed to its comment map:
OrderProcessor.process(Order order) throws Exception
1. validation empty items, non-positive total
2. pricing 18% tax, inline BigDecimal math
3. payment provider string, new StripeClient(...), HTTP call
4. persistence DriverManager, prepared statement, status 'PAID'
5. notification JavaMail Session, MimeMessage, Transport.send
6. audit Files.writeString to audit.log
Now put a name against each line — not a technical layer, but the group that shows up with a ticket:
| Step | Who forces a change | Example request |
|---|---|---|
| Validation | Product / risk | ”Reject orders over ₹2,00,000 without KYC” |
| Pricing and tax | Finance | ”GST drops to 12% next quarter” |
| Payment | Payments team | ”Add Razorpay as a fallback provider” |
| Persistence | DBA / platform | ”Orders move to a new schema; add a status column” |
| Notification | Marketing / CX | ”Also send an SMS, and reword the subject line” |
| Audit | Compliance | ”Audit must be JSON and go to the log pipeline” |
Six audiences, one file. That is the whole diagnosis. Any of those six tickets lands in the same method, the same code review, and the same regression surface — a tax change can break email, and a schema change can break validation, because they share a try block and a set of local variables.
The scoreboard from Part 1 makes it concrete:
Change the tax rate -> edit the class that also charges cards and sends mail
Test the tax rate -> needs a DB, an SMTP server, and a live Stripe key
Split by audience, not by verb
The refactor is mechanical once you have the table: each row becomes a type, and OrderProcessor keeps only the sequence.
1. Validation → OrderValidator
Rules that answer to product and risk, with no I/O and no knowledge of what happens next.
public class OrderValidator {
public void validate(Order order) {
if (order.items().isEmpty()) {
throw new InvalidOrderException("order has no line items");
}
if (order.total().compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidOrderException("order total must be positive: " + order.total());
}
}
}
A dedicated exception type matters more than it looks: the processor no longer needs to guess whether an IllegalArgumentException came from a rule or from a bug in the payment client.
2. Pricing → TaxCalculator
Finance’s numbers, in a class finance’s questions can reach without touching anything else. The rate is a constructor parameter, so a rate change becomes config — not a code edit.
public class TaxCalculator {
private final BigDecimal rate;
public TaxCalculator(BigDecimal rate) {
this.rate = Objects.requireNonNull(rate, "rate");
}
public BigDecimal taxFor(Order order) {
return order.total()
.multiply(rate)
.setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal payableTotal(Order order) {
return order.total().add(taxFor(order));
}
}
Note: Pulling money math out of an I/O-heavy method usually surfaces a real bug. The inline version in Part 1 never called setScale, so 100.00 × 0.18 could reach the gateway with more fraction digits than the currency allows. You only notice that when the rule is readable on its own.
3. Persistence → OrderRepository
The DBA’s territory: connections, SQL, column names, transactions. Keep it an interface so the policy depends on the verb and not on JDBC.
public interface OrderRepository {
void markPaid(String orderId, BigDecimal amountCharged, String paymentReference);
}
public class JdbcOrderRepository implements OrderRepository {
private final DataSource dataSource;
public JdbcOrderRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void markPaid(String orderId, BigDecimal amountCharged, String paymentReference) {
String sql = """
insert into orders (id, total, payment_ref, status)
values (?, ?, ?, 'PAID')
""";
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, orderId);
ps.setBigDecimal(2, amountCharged);
ps.setString(3, paymentReference);
ps.executeUpdate();
} catch (SQLException e) {
throw new OrderPersistenceException("could not mark order paid: " + orderId, e);
}
}
}
A schema change now edits one file that nobody else has a reason to open.
4. Notification → NotificationService
Marketing owns wording and channels. The processor should say what happened, never how to tell someone.
public interface NotificationService {
void orderConfirmed(Order order, BigDecimal amountCharged);
}
public class EmailNotificationService implements NotificationService {
private final EmailNotifier notifier;
public EmailNotificationService(EmailNotifier notifier) {
this.notifier = notifier;
}
@Override
public void orderConfirmed(Order order, BigDecimal amountCharged) {
notifier.orderConfirmed(order, amountCharged); // JavaMail lives behind here
}
}
That looks like a thin wrapper today, and it is. The point is the shape of the call site: one domain event, one method. When the SMS ticket arrives, it becomes a second implementation or a fan-out inside this class — and OrderProcessor does not change.
5. Audit → AuditLog
Compliance’s format and compliance’s destination, in a file only compliance has a reason to open.
public interface AuditLog {
void orderPaid(String orderId, BigDecimal amountCharged);
}
public class LoggingAuditLog implements AuditLog {
private static final Logger log = LoggerFactory.getLogger(LoggingAuditLog.class);
@Override
public void orderPaid(String orderId, BigDecimal amountCharged) {
log.info("event=order_paid order_id={} amount={}", orderId, amountCharged);
}
}
What OrderProcessor keeps
After the split, the policy class holds exactly one responsibility: the order in which things happen. That is a real job, and it has its own audience — whoever decides that a failed charge must not persist an order.
public class OrderProcessor {
private final OrderValidator validator;
private final TaxCalculator taxes;
private final PaymentGateway payments;
private final OrderRepository orders;
private final NotificationService notifications;
private final AuditLog audit;
public OrderProcessor(OrderValidator validator,
TaxCalculator taxes,
PaymentGateway payments,
OrderRepository orders,
NotificationService notifications,
AuditLog audit) {
this.validator = validator;
this.taxes = taxes;
this.payments = payments;
this.orders = orders;
this.notifications = notifications;
this.audit = audit;
}
public void process(Order order) {
validator.validate(order);
BigDecimal payable = taxes.payableTotal(order);
PaymentResult result = payments.charge(order, payable);
orders.markPaid(order.id(), payable, result.reference());
audit.orderPaid(order.id(), payable);
notifications.orderConfirmed(order, payable);
}
}
Six lines of policy, six collaborators. Two things worth saying out loud:
- The constructor got long, and that is honest. The dependencies did not appear during the refactor; they were always there, hidden behind
newandSystem.getenv. A long constructor is a visible cost, not a new one — and it is the signal you use later to decide whether some of these belong in a smaller unit. Where the wiring lives is Dependency Inversion’s problem. - The ordering decision is now readable. Notification comes after persistence deliberately: if the insert fails, no customer gets a confirmation for an order the system does not have. In the god method that ordering was an accident of where someone pasted code.
Re-run the scoreboard:
Change the tax rate -> TaxCalculator (or its config) 1 file
Reword the email -> EmailNotificationService 1 file
Add a validation rule -> OrderValidator 1 file
New audit format -> LoggingAuditLog 1 file
Change the schema -> JdbcOrderRepository 1 file
Change the sequence -> OrderProcessor 1 file
The test that proves it
The strongest evidence for an SRP split is not the class diagram — it is a test that used to be impossible. Finance’s rule, with no database, no mail server, and no network:
@Test
void applies_eighteen_percent_tax_rounded_to_two_places() {
TaxCalculator taxes = new TaxCalculator(new BigDecimal("0.18"));
Order order = new Order("o-1", "a@example.com",
List.of(new LineItem("sku-1", 1, new BigDecimal("99.99"))),
new BigDecimal("99.99"));
// 99.99 * 0.18 = 17.9982 -> 18.00, so payable is 117.99
assertEquals(new BigDecimal("117.99"), taxes.payableTotal(order));
}
And the sequencing rule, with fakes standing in for everything else:
@Test
void does_not_notify_when_persistence_fails() {
List<String> notified = new ArrayList<>();
OrderProcessor processor = new OrderProcessor(
new OrderValidator(),
new TaxCalculator(new BigDecimal("0.18")),
(order, amount) -> new PaymentResult("pay_123"),
(id, amount, ref) -> { throw new OrderPersistenceException("db down"); },
(order, amount) -> notified.add(order.id()),
(id, amount) -> { });
assertThrows(OrderPersistenceException.class, () -> processor.process(anOrder()));
assertTrue(notified.isEmpty());
}
If a split does not make some test cheaper, it probably was not a split along a real seam. That is the check to run before merging a refactor like this one.
What we deliberately did not fix
The point of a series is to stop when the current principle is satisfied. Three things are still ugly on purpose:
PaymentGatewayhas one implementation. The providerif/elsefrom Part 1 has moved behind the interface, but choosing between Stripe and Razorpay is still an edit somewhere. Making that an addition is Open-Closed’s job.NotificationServiceandEmailNotifierare two types with one behavior. Today that is a thin indirection; it earns its keep when a second channel arrives, and Interface Segregation decides how to shape it.- Nobody wires this yet. There is no
main, no@Configuration, no container. Constructor parameters are only half of DIP; the other half is where the concrete objects get created.
Resisting those is not laziness — it is the difference between a refactor and a rewrite. One principle per commit keeps the diff reviewable and the blame history readable.
When not to split
SRP is the principle most likely to be over-applied, because splitting always feels productive.
- One audience, one class — even if it is long. A 150-line pricing engine that only finance can change is cohesive. Splitting it into
DiscountStep,TaxStep, andRoundingStepadds files without removing a reason to change. - Do not split by technical noun.
OrderDataHelper,OrderUtils, andOrderManagerare three names for “stuff I moved out.” If you cannot name the audience the class answers to, the split has no boundary. - Two rules that always change together belong together. If every “add a channel” ticket edits both classes, you drew the line in the wrong place — that is coupling wearing two file names. A thin CRUD endpoint that validates and saves is already one reason to change; wait for the second audience.
Cheat sheet
SRP = one reason to change = one audience that can force an edit
Diagnose:
list every step in the method
name the team that would file the ticket for it
two or more teams -> two or more classes
Split by: audience (finance, DBA, marketing, compliance)
Not by: method count, line count, or technical noun
Keep in the policy class: the sequence and the failure rules
Push out: rules, math, SQL, templates, log formats
Proof it worked: a test that needed I/O before, and does not now
Do:
- Name the actor before you claim a violation — “finance and the DBA both edit this file.”
- Give each extracted rule its own exception type so callers can tell a rule from a bug.
- Keep data types dumb records, coordination in a thin class, and config with the class that uses it.
- Stop splitting once every step answers to exactly one team.
Don’t:
- Split until you have one class per method and an unreadable flow.
- Move code into
*Helperor*Utilsand call it a responsibility. - Separate two rules that every ticket changes together, or extract a seam for a variant your product does not have.
Wrap-up
The god OrderProcessor was never wrong because it was long. It was expensive because six different teams could each force an edit to the same method, so every small change carried five other teams’ risk. Splitting by audience — OrderValidator, TaxCalculator, OrderRepository, NotificationService, AuditLog — turns each of those tickets into a one-file diff and makes the interesting logic testable without a database.
What is left in OrderProcessor is the sequence and the failure policy, which is a genuine single responsibility with a genuine owner. The seams we left rough — one payment implementation, one notification channel, no wiring — are the next three letters, and they are much easier to see now that each responsibility has a name.