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:

StepWho forces a changeExample request
ValidationProduct / risk”Reject orders over ₹2,00,000 without KYC”
Pricing and taxFinance”GST drops to 12% next quarter”
PaymentPayments team”Add Razorpay as a fallback provider”
PersistenceDBA / platform”Orders move to a new schema; add a status column”
NotificationMarketing / CX”Also send an SMS, and reword the subject line”
AuditCompliance”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 new and System.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:

  • PaymentGateway has one implementation. The provider if/else from 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.
  • NotificationService and EmailNotifier are 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, and RoundingStep adds files without removing a reason to change.
  • Do not split by technical noun. OrderDataHelper, OrderUtils, and OrderManager are 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 *Helper or *Utils and 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.

Next optional step in the series Grow a payment type-code without rewriting the processor. Open-Closed: Add Behavior Without Rewriting the Core