Module 13 · Lesson 02

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.

Intermediate 10 min read Updated 2026-11-03

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.

The one-sentence test
For any class, finish the sentence: this class is responsible for ______. If the blank needs an and, the class has two jobs. If the blank is vague (handling business logic), the class has no job — it has a name.

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;
    }
}
The trap: a rule framework for one campaign
If the requirement is a single 10 percent launch offer, a rule engine is a week of work and a debugging surface you now own. Write the requirement, test it, and leave the seam — one interface, one implementation — for the second campaign. Extensibility you can see is worth paying for; extensibility you imagine is not.

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 elementWeak versionHonest version
Authorisereturns booleanreturns Authorised, Declined or Pending
Refundvoid refund(Money)RefundResult supporting Unsupported for the provider that cannot
Captureimplicit inside authoriseexplicit 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 Money class does not need an interface; nothing will ever replace arithmetic.
  • Do not invert a stable library. Wrapping List to prove you can swap it out is indirection without a scenario.
The reviewer question to answer before they ask it
For every interface you introduce, be ready to say: this exists because X may change, and here is the second implementation or the test double that proves the seam works. One sentence. If you cannot produce it, collapse the interface.
Instead
An interface for every class, each with a single implementation.
Do this
An interface only where the dependency is volatile, external, or needs a fake in a test.
Instead
Controller to service to manager to helper, each forwarding its arguments.
Do this
Collapse the chain until each remaining layer owns a decision.
Instead
A BaseCheckoutService every checkout extends.
Do this
Compose the collaborators as fields, so behaviour changes without touching a parent.
Instead
A constructor taking ten injected collaborators.
Do this
The three collaborators the class actually uses, with the rest moved to where they belong.
Instead
A rule engine written for the first campaign.
Do this
One implementation plus a named seam, paid for when the second campaign arrives.

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.

Checkpoint

Answer all 3 questions to finish the lesson.
1. A checkout facade has one method doing validation, pricing, reservation, payment and email. Which split best follows single responsibility?
SRP is about who asks for a change, not about the number of statements. Pricing teams, inventory teams and payment teams change for different reasons, so those are the seams.
2. When is a one-implementation interface justified?
The interface buys replaceability or testability. If neither applies today, it is a file that must be maintained for no return.
3. Two payment providers behave differently on refunds. The most honest design is:
The contract should admit the states that exist. Throwing hides the difference until production; instanceof pushes knowledge of implementations into callers.