The ticket that opened this series was “also send an SMS when payment succeeds,” landing on a nine-hundred-line OrderProcessor. Here is the same ticket against the finished design: one new class, one line in the wiring, and OrderProcessor is not opened, not recompiled, and not re-reviewed.

This post is the capstone. Parts 2–6 each took one letter against the god method from SOLID Roadmap; this one shows the coherent end state, the three naming collisions the series had to settle, and — the part that matters more — the seams we deliberately did not build.

Where Part 1 left us

The starting point was one method doing validation, tax math, provider selection, a SQL insert, an SMTP send, and an audit write — four costs, five letters, one file:

Add a payment provider   -> edit the if/else in the paid-order policy       (OCP)
Add an SMS channel       -> edit the same method again                      (SRP)
Test the tax rule        -> needs a DB, an SMTP server, and a live key      (DIP)
Change the tax rate      -> touch the class that also charges cards         (SRP)

The end state, one package at a time

Read this top to bottom as a dependency direction: everything below the first block depends upward on it, and nothing in the first block knows any of the rest exists.

com.geekmonks.orders          Order, LineItem                       data
                              OrderProcessor                        policy: the sequence
                              OrderValidator, TaxCalculator         rules, no I/O
                              PaymentGateway, RefundService         seams the policy owns
                              PaymentResult, PaymentGateways
                              OrderRepository, OrderNotifier, AuditLog

com.geekmonks.orders.stripe   StripeGateway         -> orders + Stripe SDK
com.geekmonks.orders.razorpay RazorpayGateway       -> orders + Razorpay SDK
com.geekmonks.orders.offline  OfflineGateway        -> orders + PaymentLedger
com.geekmonks.orders.jdbc     JdbcOrderRepository   -> orders + JDBC
com.geekmonks.orders.smtp     SmtpOrderNotifier     -> orders + JavaMail
com.geekmonks.orders.sms      SmsOrderNotifier      -> orders + SMS SDK

com.geekmonks.app             Application           -> imports everything, calls `new`

Nineteen types where there was one, which sounds like the ceremony this post is supposed to be arguing against. The defence is in the right-hand column: every type in the first block is pure — no SDK, no connection, no environment variable — and every type below it is a translation layer that one team owns. The ArchUnit rule from Part 6 turns that “nothing knows” into a failing build rather than a review comment.

Which letter is responsible for each file:

FilePut there byWhose tickets land on it
OrderValidatorSRPProduct / risk
TaxCalculatorSRPFinance
OrderRepository + JdbcOrderRepositorySRP, DIPDBA / platform
AuditLog + LoggingAuditLogSRPCompliance
PaymentGateway + one class per providerOCP, DIPPayments / partnerships
PaymentResult (sealed)LSPPayments, with every caller
RefundServiceLSP, ISPRefunds / support
OrderNotifier + channelsSRP, OCPMarketing / CX
OrderProcessorSRPWhoever owns the failure rules
ApplicationDIPWhoever configures environments

Three names the series had to settle

Each post optimised for its own letter and named things accordingly, which is exactly what happens when five people refactor the same class on five branches. Merging them forces three decisions.

PaymentResult is sealed, not a boolean. Parts 3 and 6 used a record with a boolean flag on it. Part 4 replaced it with a sealed hierarchy so a gateway that refuses to attempt a charge has somewhere honest to answer. The sealed version wins because it subsumes the other: a boolean cannot distinguish “the card was declined” from “this gateway does not take euros,” and those two outcomes have different next steps.

public sealed interface PaymentResult {

    record Captured(String transactionId, BigDecimal amount) implements PaymentResult {}

    record Declined(String reason) implements PaymentResult {}

    /** The gateway refused to attempt the charge — cap, currency, unsupported method. */
    record Unsupported(String reason) implements PaymentResult {}
}

One notification type, not two. Part 2 extracted NotificationService and left EmailNotifier behind it; Part 6 renamed the abstraction to OrderNotifier. Two types with one behaviour was honest as an intermediate step and is pure indirection now, so the end state keeps one interface named for the domain event:

public interface OrderNotifier {
    void orderConfirmed(Order order, BigDecimal amountCharged);
}

Refunds are a sibling interface, not a sub-interface. Part 4 fixed OfflineGateway’s UnsupportedOperationException with RefundablePaymentGateway extends PaymentGateway. Part 5 then pointed out the cost from the client’s side: a refund handler that inherits charge depends on a method it never calls. The end state splits rather than extends, and drops providerCode() from the interface too — the registry holds the keys, because OrderProcessor never asks a gateway its name.

public interface PaymentGateway {

    /**
     * Returns a non-null result for every business outcome, including refusal.
     * Throws only for infrastructure failure: network, auth, timeout.
     */
    PaymentResult charge(Order order, BigDecimal amount);
}

public interface RefundService {
    PaymentResult refund(String transactionId, BigDecimal amount);
}

Capable providers declare both; the counter-payment gateway declares one and the compiler enforces the difference:

public final class StripeGateway implements PaymentGateway, RefundService { /* ... */ }

public final class OfflineGateway implements PaymentGateway { /* cannot refund, does not claim to */ }

Do not read this section as a plan. Three names got settled here because five posts arrived at the same code from different directions — not because a healthy refactor renames its abstractions three times.

The policy class, finished

OrderProcessor keeps exactly one responsibility: what happens in which order, and what happens when a step fails.

public final class OrderProcessor {

    private final OrderValidator validator;
    private final TaxCalculator taxes;
    private final PaymentGateways gateways;
    private final OrderRepository orders;
    private final OrderNotifier notifier;
    private final AuditLog audit;

    public OrderProcessor(OrderValidator validator, TaxCalculator taxes, PaymentGateways gateways,
                          OrderRepository orders, OrderNotifier notifier, AuditLog audit) {
        this.validator = Objects.requireNonNull(validator);
        this.taxes = Objects.requireNonNull(taxes);
        this.gateways = Objects.requireNonNull(gateways);
        this.orders = Objects.requireNonNull(orders);
        this.notifier = Objects.requireNonNull(notifier);
        this.audit = Objects.requireNonNull(audit);
    }

    public void process(Order order, String providerCode) {
        validator.validate(order);

        BigDecimal payable = taxes.payableTotal(order);
        PaymentResult result = gateways.forCode(providerCode).charge(order, payable);

        switch (result) {
            case PaymentResult.Captured captured -> {
                orders.markPaid(order.id(), payable, captured.transactionId());
                audit.orderPaid(order.id(), payable);
                notifier.orderConfirmed(order, payable);
            }
            case PaymentResult.Declined declined -> {
                audit.paymentFailed(order.id(), declined.reason());
                throw new PaymentDeclinedException(order.id(), declined.reason());
            }
            case PaymentResult.Unsupported unsupported ->
                    throw new UnsupportedProviderException(providerCode, unsupported.reason());
        }
    }
}

Three things in that body are load-bearing, and all three were invisible in the god method:

  • The sequence is a decision, written down. Persist, then audit, then notify. If the insert fails, nobody gets a confirmation for an order the system does not have.
  • The switch has no default. A fourth PaymentResult breaks this file at compile time, which is the point of sealing a set you own. Providers stay open for extension; outcomes stay closed.
  • providerCode is data, not a dependency. A String from checkout flows through to a map lookup. No provider name appears anywhere in the package.

Note: Six constructor parameters is the honest cost of six responsibilities — they were all in the god method too, hidden behind new and System.getenv. Grouping them into an OrderSideEffects holder shortens the signature without removing a dependency; a parameter object that exists to hide a parameter count makes the class less readable, not more. Split the class only if some of those six stop being needed together.

The SMS ticket, now

Part 1’s opening request: also send an SMS when payment succeeds. Marketing owns channels, and the policy must not learn a third one exists. The channel is a new OrderNotifier, and fan-out is a composite implementation of the same interface:

public final class FanOutOrderNotifier implements OrderNotifier {

    private static final Logger log = LoggerFactory.getLogger(FanOutOrderNotifier.class);

    private final List<OrderNotifier> channels;

    public FanOutOrderNotifier(List<OrderNotifier> channels) {
        this.channels = List.copyOf(channels);
    }

    @Override
    public void orderConfirmed(Order order, BigDecimal amountCharged) {
        for (OrderNotifier channel : channels) {
            try {
                channel.orderConfirmed(order, amountCharged);
            } catch (RuntimeException e) {
                log.warn("channel={} failed for order_id={}", channel.getClass().getSimpleName(), order.id(), e);
            }
        }
    }
}

That catch is a policy decision worth stating out loud: a dead SMS provider must not fail an order whose money already moved. It lives here rather than in OrderProcessor because “how hard we try to tell the customer” belongs to the notification owner, not to the payment sequence.

Price the ticket:

A  SmsOrderNotifier.java       ~20 lines around an SMS SDK
A  SmsOrderNotifierTest.java   one vendor, no order logic
M  Application.java            one line in the channel list
   ------------------------------------------------------------
   OrderProcessor.java         untouched
   OrderProcessorTest.java     untouched, still green

The composition root

One file knows every concrete type, reads every environment variable, and calls every new:

public final class Application {

    public static void main(String[] args) {
        StripeGateway stripe = new StripeGateway(new StripeClient(env("STRIPE_KEY")));

        PaymentGateways gateways = new PaymentGateways(Map.of(
                "STRIPE", stripe,
                "RAZORPAY", new RazorpayGateway(new RazorpayClient(env("RAZORPAY_KEY"))),
                "OFFLINE", new OfflineGateway(new JdbcPaymentLedger(dataSource()))));

        OrderNotifier notifier = new FanOutOrderNotifier(List.of(
                new SmtpOrderNotifier(Session.getInstance(smtpProps()), env("MAIL_FROM")),
                new SmsOrderNotifier(new TwilioClient(env("TWILIO_TOKEN")))));

        OrderProcessor processor = new OrderProcessor(
                new OrderValidator(),
                new TaxCalculator(new BigDecimal(env("TAX_RATE"))),
                gateways,
                new JdbcOrderRepository(dataSource()),
                notifier,
                new LoggingAuditLog());

        RefundHandler refunds = new RefundHandler(stripe);   // same object, RefundService role only

        new OrderHttpServer(processor, refunds).start(8080);
    }
}

Read the last two interesting lines. stripe is one instance handed to two clients under two narrow types, so the refund handler cannot accidentally charge a card. And the tax rate is a string from the environment: finance’s quarterly change is now a config edit with no deploy of new code. A Spring @Configuration class does the same job with the container calling it, and nothing above the root changes either way.

Two tests that prove the seams

A refactor that does not make a test cheaper did not find a real seam. First, the sequencing rule — the one that was unwritable in Part 1 because it needed a live key:

class OrderProcessorTest {

    private final List<String> persisted = new ArrayList<>();
    private final List<String> notified = new ArrayList<>();

    private OrderProcessor processorWith(PaymentGateway gateway) {
        return new OrderProcessor(
                new OrderValidator(),
                new TaxCalculator(new BigDecimal("0.18")),
                new PaymentGateways(Map.of("STRIPE", gateway)),
                (id, amount, ref) -> persisted.add(id),
                (order, amount) -> notified.add(order.id()),
                NO_AUDIT);
    }

    @Test
    void declined_payment_persists_nothing_and_notifies_nobody() {
        PaymentResult declined = new PaymentResult.Declined("insufficient_funds");
        OrderProcessor processor = processorWith((order, amount) -> declined);

        assertThrows(PaymentDeclinedException.class, () -> processor.process(anOrder(), "STRIPE"));
        assertTrue(persisted.isEmpty());
        assertTrue(notified.isEmpty());
    }
}

Three of the six collaborators are lambdas — NO_AUDIT is a four-line anonymous class only because AuditLog has two methods. That is the ISP dividend: one-method interfaces make hand-written doubles disappear, so there is no mocking framework and nothing to hide behind. Second, the fan-out rule, which is the kind of thing that quietly breaks in production:

@Test
void a_failing_sms_channel_does_not_lose_the_email() {
    List<String> emailed = new ArrayList<>();
    OrderNotifier notifier = new FanOutOrderNotifier(List.of(
            (order, amount) -> { throw new IllegalStateException("sms provider down"); },
            (order, amount) -> emailed.add(order.id())));

    notifier.orderConfirmed(anOrder(), new BigDecimal("118.00"));

    assertEquals(List.of("o-1"), emailed);
}
OrderProcessorTest > declined_payment_persists_nothing_and_notifies_nobody()  PASSED
FanOutOrderNotifierTest > a_failing_sms_channel_does_not_lose_the_email()     PASSED

2 tests, 0 skipped -- 0.04s   (no network, no DB, no SMTP, no keys)

Add the contract test from Part 4 across StripeGateway, RazorpayGateway, and OfflineGateway, and every future provider inherits the promise along with the interface.

The order it actually happened

Five commits, one letter each, in the order that made each next step visible:

1  split by audience           OrderValidator, TaxCalculator, OrderRepository, AuditLog   (SRP)
2  seam where variants arrive  PaymentGateway + one class per provider + registry         (OCP)
3  make the subtypes honest    sealed PaymentResult; OfflineGateway drops refund          (LSP)
4  shape types to callers      PaymentGateway / RefundService / PaymentReporting          (ISP)
5  move `new` to the edge      constructor injection + Application composition root       (DIP)

SRP first is not a rule, but it is a good default: until each responsibility has a name, you cannot see which one needs a seam. After that the order is set by whichever ticket hurts — a team that cannot unit-test anything usually starts at DIP and finds the SRP splits fall out on their own.

One principle per commit is the part worth copying. Doing all five at once produces a diff nobody can review, which is how a refactor turns into a rewrite that gets abandoned at 60%.

When to stop

Everything above was pulled by a real ticket. Here is what the end state deliberately does not contain, and why each one would have been ceremony:

  • No OrderProcessorImpl. OrderProcessor is a concrete class with one implementation. There is no second one and no test needs a double of it, so an interface here would be two files where one does the job.
  • OrderValidator and TaxCalculator stayed concrete. They take no I/O and are injected as classes, not interfaces. Interfaces went only where a boundary is crossed — payment, storage, notification, audit — because that is where variants and infrastructure live.
  • No TaxStrategy. One country, one rate, one config key. An interface would describe a constant with extra indirection.
  • No CurrencyConverter seam. Multi-currency is on a roadmap, not in a ticket. Speculative seams are the ones that turn out to be shaped wrong when the feature finally lands.
  • AuditLog keeps two methods. orderPaid and paymentFailed have one client that calls both, and they change together. ISP’s answer to a cohesive interface is to leave it whole.
  • No class per rule. OrderValidator holds every validation rule until two audiences own different ones; NonEmptyItemsRule and PositiveTotalRule would buy a registry and a lookup to read two if statements.
  • No event bus. “Publish OrderPaid and let subscribers react” removes the readable sequence from OrderProcessor and replaces a stack trace with a correlation-id hunt. That is a distributed-systems trade, not a SOLID one.

The line to hold, unchanged from Part 1: the trigger is a change request you can name. “A second provider is funded,” “finance changes the rate quarterly,” “I cannot test this without a mail server” — all sufficient. “This violates OCP” is not, and neither is “we might need it later.”

Note: Published libraries play by different rules. When your callers are outside the repository, you need the abstraction before the second caller exists, because you cannot refactor code you do not own. Application code gets to wait for evidence; public API does not.

Change request → principle → file

The practical form of all six posts. Find the row, touch the file:

TicketLetterWhat you touch
”GST drops to 12% next quarter”SRPTAX_RATE config — no code
”Reject orders over ₹2,00,000 without KYC”SRPOrderValidator
”Add PayU as a provider”OCP+ PayUGateway, one line in Application
”Also send an SMS / a WhatsApp message”SRP, OCP+ one OrderNotifier, one line in Application
”Swap SMTP for SendGrid”DIP+ SendGridOrderNotifier, one line in Application
”Counter payments cannot be refunded”LSPOfflineGateway drops RefundService — compile error, not a 2 a.m. page
”Reconciliation needs a settlement batch id”ISPPaymentReporting + its real implementations only
”Orders move to a new schema”SRP, DIPJdbcOrderRepository
”Audit must be JSON on the log pipeline”SRPLoggingAuditLog
”Notification must not fail a paid order”—FanOutOrderNotifier — policy change, edit the policy
”Retry a timed-out charge twice”—+ RetryingGateway decorator, one line in Application
”Refunds need approval above ₹50,000”—RefundHandler — new business rule, edit the owner

The last three rows are the ones people misread. SOLID never promised you would stop editing code — it promised that a known, repeating variation would not force an edit. New policy edits the policy file, and that is the design working.

Cheat sheet

S  one reason to change   -> split by audience; keep the sequence in the policy
O  add, don't edit        -> interface per repeating variant; map, not switch
L  subtypes keep promises -> widen the contract (sealed result) or split the capability
I  client-shaped types    -> one interface per client conversation; lambdas as doubles
D  inject the details     -> no `new` on infrastructure; one composition root

Interfaces go at boundaries: payment, storage, notification, audit
Concrete classes stay concrete: validators, calculators, the policy itself

Trigger:    a named ticket that the current shape makes expensive
Stop when:  one implementation, no test double asking, no funded variant
Scoreboard: files touched per change / unrelated tests broken / I/O-free unit tests
Order:      one principle per commit; SRP first unless testability hurts more

Do:

  • Name the ticket and the audience before you claim a violation.
  • Keep the sequence and the failure rules in one thin policy class, and write them down as tests.
  • Put the seam on the axis your git log shows changing, and let a map resolve the variant.
  • Model refusal as a return value so no implementation has to lie to satisfy a contract.
  • Let one capable class implement several narrow interfaces, and hand it to each client under the narrowest type.
  • Push every new on infrastructure into one composition root, and keep that file boring.

Don’t:

  • Add an interface with one implementation and no test double asking for it.
  • Refactor working code because of an acronym instead of a cost.
  • Abstract a variation your product does not have, or split a cohesive interface every client uses whole.
  • Hide a long constructor behind a parameter object instead of asking whether the class does too much.
  • Ship all five letters in one pull request.

Wrap-up

The finished design is not clever. It is one policy class holding a sequence, four rules and calculators with no I/O, five interfaces at the boundaries where variation and infrastructure actually live, one class per provider and per channel, and a main method that knows every secret. Every one of those pieces exists because a specific ticket was expensive without it.

That is the whole method: apply a letter when a named change is being blocked, apply one per commit, and check the scoreboard afterwards — fewer files touched, no unrelated tests broken, the interesting rules testable in milliseconds. When the scoreboard does not move, you added indirection, not design, and the honest move is to delete it.

Definitions, the audience table, and the god OrderProcessor this all started from are in SOLID Roadmap — keep that page as the glossary, and come back to the row-by-row table above the next time a ticket lands and you are deciding which file to open.