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:
| File | Put there by | Whose tickets land on it |
|---|---|---|
OrderValidator | SRP | Product / risk |
TaxCalculator | SRP | Finance |
OrderRepository + JdbcOrderRepository | SRP, DIP | DBA / platform |
AuditLog + LoggingAuditLog | SRP | Compliance |
PaymentGateway + one class per provider | OCP, DIP | Payments / partnerships |
PaymentResult (sealed) | LSP | Payments, with every caller |
RefundService | LSP, ISP | Refunds / support |
OrderNotifier + channels | SRP, OCP | Marketing / CX |
OrderProcessor | SRP | Whoever owns the failure rules |
Application | DIP | Whoever 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 fourthPaymentResultbreaks this file at compile time, which is the point of sealing a set you own. Providers stay open for extension; outcomes stay closed. providerCodeis data, not a dependency. AStringfrom 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.OrderProcessoris 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. OrderValidatorandTaxCalculatorstayed 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
CurrencyConverterseam. 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. AuditLogkeeps two methods.orderPaidandpaymentFailedhave 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.
OrderValidatorholds every validation rule until two audiences own different ones;NonEmptyItemsRuleandPositiveTotalRulewould buy a registry and a lookup to read twoifstatements. - No event bus. “Publish
OrderPaidand let subscribers react” removes the readable sequence fromOrderProcessorand 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:
| Ticket | Letter | What you touch |
|---|---|---|
| ”GST drops to 12% next quarter” | SRP | TAX_RATE config — no code |
| ”Reject orders over ₹2,00,000 without KYC” | SRP | OrderValidator |
| ”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” | LSP | OfflineGateway drops RefundService — compile error, not a 2 a.m. page |
| ”Reconciliation needs a settlement batch id” | ISP | PaymentReporting + its real implementations only |
| ”Orders move to a new schema” | SRP, DIP | JdbcOrderRepository |
| ”Audit must be JSON on the log pipeline” | SRP | LoggingAuditLog |
| ”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
newon 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.