SOLID where it earns its place
Five principles, five places they pay for themselves in a commerce codebase, and the over-abstraction they are used to justify.
What you will be able to do
- Apply each SOLID principle to a concrete commerce responsibility
- Judge when an abstraction costs more than the duplication it removes
- Recognise the four over-engineering smells SOLID is used to justify
- Explain a design decision in one sentence a reviewer can accept or reject
Why the principles sound like advice
Every one of these principles is a sentence about future change, and future change is unknowable. That is why they are argued about endlessly: applied with evidence, they make a codebase cheap to extend; applied by habit, they produce six layers where two would do. The only reliable test is to name the change you are protecting against. If you cannot name it, you have not applied a principle, you have added a layer.
Single responsibility: the checkout facade that did everything
A Hybris checkout facade typically starts as one method that validates the cart, recalculates totals, applies promotions, reserves stock, authorises payment, creates the order and sends an email. Every new requirement lands in the same file, and every bug is a diff in the same file. Splitting it by reason to change — pricing, inventory, payment, fulfilment notification — is the version of SRP that actually helps, because each piece then has an owner and a test.
// one reason to change: how a cart becomes payable
final class Checkout {
private final Pricing pricing;
private final Inventory inventory;
private final Payments payments;
private final Orders orders;
Order place(Cart cart, PaymentMethod method, IdempotencyKey key) {
var priced = pricing.price(cart);
var reservation = inventory.reserve(priced.lines()); // throws if short
try {
var payment = payments.authorise(method, priced.total(), key);
return orders.create(priced, reservation, payment);
} catch (RuntimeException e) {
reservation.release(); // the only side effect we must undo
throw e;
}
}
}Note what this class does not do: it does not know how a promotion is calculated, how stock is stored, or which gateway is in use. It knows the order of operations and what to undo when one fails. That is a single, defensible responsibility.
Open for extension: the promotion engine
Discounts are the classic case where a sealed hierarchy beats an if-chain. Each rule answers one question and returns a discount; the engine applies them in a defined order. A new campaign is a new class, not an edit to a 400-line method. In Java 21, a sealed interface plus pattern matching gives you exhaustiveness checking, so adding a rule type forces you to handle it.
sealed interface DiscountRule permits PercentOff, BuyXGetY, FreeShipping, Coupon {
Money apply(PricedCart cart);
}
final class PromotionEngine {
private final List<DiscountRule> rules; // ordered, not a set
PricedCart apply(PricedCart cart) {
var total = cart;
for (var rule : rules) total = total.withDiscount(rule.apply(total));
return total;
}
}Liskov: the payment provider that lies
Every payment provider implements the same interface, but they do not behave the same way. One throws on decline, another returns a status; one settles asynchronously; one supports partial refunds and another does not. A subclass that cannot honour the contract — for example a provider whose refund silently does nothing — breaks every caller that trusted the interface. The fix is to make the contract honest: return a result type that admits pending and unsupported states, rather than pretending every call resolves immediately.
| Contract element | Weak version | Honest version |
|---|---|---|
| Authorise | returns boolean | returns Authorised, Declined or Pending |
| Refund | void refund(Money) | RefundResult supporting Unsupported for the provider that cannot |
| Capture | implicit inside authorise | explicit step, because some providers allow delayed capture |
Interface segregation: the port that only needs one method
In a Spartacus storefront, a component should not depend on a service exposing twelve methods when it calls one. Narrow ports are also how you keep a UI test fast: the component depends on PriceFormatter, not on the whole product service. The test then needs one stub, not a mock graph.
Dependency inversion: invert only what moves
- Invert external systems. Tax, payment, address validation and email change vendors; the interface is the seam and the fake is how you test.
- Invert the clock and the ID generator. Both make deterministic tests possible and both are trivial to wrap.
- Do not invert your own value objects. A
Moneyclass does not need an interface; nothing will ever replace arithmetic. - Do not invert a stable library. Wrapping
Listto prove you can swap it out is indirection without a scenario.
BaseCheckoutService every checkout extends.What to say in the round
Do not narrate the acronyms. Narrate the decisions: I put the discount rules behind one interface because campaigns change weekly, and I left tax as a single class because we are not going to swap the tax vendor in this round. That sentence shows judgement — which is the thing the principles were invented to describe.
Worth remembering
- SOLID is a set of trade-offs, not a checklist. Each principle buys flexibility with indirection, and indirection is paid for by whoever debugs the system at 2am.
- The single responsibility of a class is the one sentence you can say about it without using the word and.
- Open for extension usually means one interface with two real implementations. A third implementation that does not exist is speculation.
- Dependency inversion is only worth it where the dependency is volatile, external, or needs to be faked in a test.