SOLID Principles in Practice
SOLID is a set of five object-design heuristics that protect a module's reasons to change. It is not an architecture, and it is not a score. TechLearningPro's course-order code lea
SOLID Principles in Practice
Advanced System Design & Software Architecture · Lesson 04 · Software Architecture Foundations
Lesson Overview
SOLID is a set of five object-design heuristics that protect a module's reasons to change. It is not an architecture, and it is not a score. TechLearningPro's course-order code learned both sides of that sentence in one year. Before an enrollment week, one class priced a cart, charged a card, wrote the entitlement, and sent the receipt. A tax fix and a copy change could not ship independently, which is a modifiability failure against the scenario in lesson 2. The following quarter, a different squad extracted an interface for every noun in the order, with one implementation each, and a one-day price correction took a week of wiring. The driver for the second abstraction did not exist. The principle was applied as a ritual.
This lesson uses SOLID where a driver from lesson 3 is real, and withholds it where the only stimulus is "we might need this." The examples are Java 21 on Spring Boot 3, in the order-placement path: a cart becomes a priced order, a discount policy adjusts the total, a repository stores the order, a receipt sender is notified after the commit. Each principle is tied to a stakeholder and a kind of change. Where a principle fights delivery speed, the lesson says so and records the reopening condition, the same way lesson 1 recorded an architecture decision.
/learn/design-patterns/solid-overview and the single-principle lessons beside it (single responsibility, open-closed, Liskov, interface segregation, dependency inversion) teach the mechanics in a pattern course, often with smaller examples. /learn/design-principles builds the wider principle vocabulary. Here the question is architectural: which variation is an architecturally significant requirement, which boundary deserves an abstraction, and which abstraction is a tax on a team that has one discount and a deadline.
Cohesion and coupling are the properties underneath the five letters. Connascence, in Page-Jones's sense, gives coupling a vocabulary finer than "high" and "low." You do not need the full catalog to stop a static call from a policy into a mail client.
Learning Objectives
By the end of this lesson you will be able to:
- State each SOLID principle as a constraint on reasons to change, contracts, or dependency direction, not as a slogan.
- Judge cohesion and coupling of an order-placement design, including a concrete LSP contract a subtype can break.
- Split a god class along stakeholder reasons to change, and wire the result with Spring constructor injection.
- Decide when a second implementation is real enough to pay for an interface, and when a concrete class is the honest design.
- Identify an LSP violation that still "works" for the original caller and would fail a substituted test.
- Explain how dependency inversion supports the testability and modifiability scenarios from lesson 2 without pulling a framework into the policy.
- Write the decision you owe the team when you consciously defer SOLID for delivery speed.
Prerequisites
- Java 21: records, interfaces, and packages. You will read a Spring
@Configurationand a constructor-injected@Service. You will not need a full Spring certification. - Lesson 2's modifiability scenario (a named change, a bound on the diff) and lesson 3's rule that a hypothetical variation is not a driver.
- The discount and tax conversation from the marketplace walkthrough. This lesson does not re-argue capture versus quote. It shapes the objects inside placement.
- Familiarity with the idea of an interface is enough. Pattern names (strategy, adapter) are optional; the design-pattern course owns them. When this lesson uses a strategy-shaped interface, it says what change the interface buys.
Detailed Core Concepts
Cohesion and coupling first
A module is cohesive when its parts change for the same reason and serve the same stakeholder. A module is coupled to another when a change in the second forces a change in the first, or when understanding the first requires the second. SOLID is a way of pushing cohesion up and coupling down at object granularity. Architecture, in the sense of lessons 1–3, decides which couplings are load-bearing (the capture path must see a recomputed total) and which are accidents (the capture path must not recompile when the receipt wording changes).
Connascence names how tight a coupling is. Two elements are connascent if a change in one requires a change in the other to keep the system correct. Weaker forms, such as agreeing on a type name, are preferable to stronger forms, such as agreeing on a positional list of arguments, a shared algorithm, or an execution order that is not in the type. A receipt sender that must be called only after save and only if save did not throw is connascence of execution, and it should be visible in one place rather than hoped for in every caller.
You optimize coupling under a scenario. Zero coupling is a system that does nothing. The order must couple to money and to an identifier. It need not couple to SMTP.
Single responsibility
Single responsibility, in Robert Martin's formulation, says a module should have one reason to change, and a reason is an actor: a stakeholder who would ask for the change. The original slogan "do one thing" is weaker, because "one thing" expands to fill the class. The actor test is usable in review.
In course placement the actors are:
- Finance and tax, who change how a total is computed.
- The learner-facing product owner, who changes what "placed" means for an entitlement.
- Support and marketing, who change the words and channel of a receipt.
- The operator, who changes how an order row is stored.
A class that formats a receipt and computes tax has two actors. A change from either one opens the class, which is the modifiability cost Q-tax was trying to bound. The repair is not a class per method. The repair is a type per actor: a pricing policy, an order that knows its lines and status, a repository, a receipt sender.
Responsibility is not size. A small class can still mix actors, and a cohesive order type can be long because orders have real behavior: adding a line, refusing an empty place, remembering the currency. Moving every method to a "manager" leaves an anemic order and a manager with the same mixed reasons. That is SRP theater.
Open-closed
Bertrand Meyer's open-closed principle says a module should be open for extension and closed for modification. In object design the practical reading is: when the driver is a new variant of a known variation point, you add a type rather than edit the type that already handles the other variants. The variation point is the abstraction. The variants are the implementations.
The principle does not say "never edit a class." A bug in the percentage formula is a modification, and it should be one. A new country VAT rule, when lesson 2's scenario says the payments adapter must not change, is an extension at the tax policy. Opening PlaceOrder to add a switch on coupon kind is the failure mode: the use case becomes the list of all discounts, and every campaign is a modification of the same reason-to-ship.
Open-closed has a bill. The variation point must be the right one. If you abstract "the entire order flow" so that a coupon can be added without editing PlaceOrder, you have frozen a workflow that product is still changing every week. You are closed against the changes you will actually receive, and open along an axis you guessed. The scenario names the axis. Without a scenario, prefer a concrete policy class and a decision that says "the second coupon kind reopens this."
Liskov substitution
Barbara Liskov's substitution principle (with Jeannette Wing's contract formulation) says that if S is a subtype of T, objects of type T may be replaced by objects of type S without breaking the desirable properties of the program. The contractual reading, which is the one you can review, is:
- Preconditions of S are no stronger than those of T. A subtype that rejects inputs the supertype accepted is a surprise.
- Postconditions of S are no weaker than those of T. A subtype that guarantees less is a surprise.
- Invariants of T hold for S.
- For mutable objects, the history constraint: S does not introduce state changes T forbade.
In this lesson the supertype is DiscountPolicy. The contract is small and testable: given a subtotal in one currency, discount returns a Money of that currency, greater than or equal to zero, and less than or equal to the subtotal. It does not throw for an unrecognized coupon. An unrecognized coupon yields a zero discount. Callers, including PlaceOrder, rely on that postcondition when they compute subtotal.minus(discount) and when they refuse to wrap the call in a coupon-specific catch.
A subtype that throws UnknownCouponException, or that returns a discount larger than the subtotal "because wholesale accounts get a credit," breaks substitution. The class can still pass a test written only against the subtype. It fails the test written against the interface, which is the only test that matters for LSP. This is why the architectural test is "any implementation we register in Spring must pass the contract suite," not "the implementation we have today works in the happy path."
LSP is also why a TaxPolicy that sometimes calls a remote service does not belong behind an interface whose other implementations are local and side-effect free, unless the contract says so. The hidden remote call strengthens the precondition (the network must be up) and weakens the latency postcondition. Lesson 2 kept the vendor off the quote path for this reason. Substitution is a quality-attribute issue, not only a typing issue.
Interface segregation
Interface segregation says clients should not be forced to depend on methods they do not use. The "interface" may be a Java interface or the public surface of a class. A fat OrderGateway that can save, search, send mail, and refund forces a receipt test to stand up a repository, and it forces a repository change to recompile the mailer.
Segregation follows actors, the same list as SRP. OrderStore is what placement writes. ReceiptSender is what placement calls after a successful commit. A test double of the sender can count calls. A test double of the store can remember an order. Neither double implements a method that returns a fake SMTP code.
Segregation taken to the extreme, one method per interface with no second client, creates a fog of types and no boundary a reviewer can remember. The test is a client: if two clients use the same cluster of methods and change together, one interface is the cohesive choice. If a client must leave methods empty, the interface is waiting to be split.
Dependency inversion
Dependency inversion says high-level policy should not depend on low-level detail. Both should depend on abstractions owned by the policy. In placement, the policy is "price the cart, apply one discount contract, persist the placed order, then ask for a receipt." The details are a JDBC insert and an email provider. If PlaceOrder constructs a JdbcOrderStore and an SmtpReceiptSender, every policy test needs a database and a mail server, and a storage change modifies the policy class. That is the dependency pointing the wrong way.
The inversion: PlaceOrder takes OrderStore and ReceiptSender in its constructor. The interfaces live next to the policy, or in a package the policy owns. JDBC and SMTP implement them from the outside and are wired by Spring. The source-code arrow points from the detail toward the policy, which is the direction lesson 5 will call the dependency rule at a larger scale. DIP is that rule at the object scale. It is not "use interfaces everywhere." A Money record is a detail the policy is allowed to know, because money is the policy's vocabulary. Inverting Money behind MonetaryAmountFactory is how a one-day fix becomes a scavenger hunt.
Spring's constructor injection is a mechanism for DIP, not the principle itself. A class that asks ApplicationContext for a concrete JDBC type has a framework and still depends on the detail. Constructor injection of the abstraction is the form that keeps the policy compilable without Spring, which is what the contract test needs.
When SOLID fights delivery speed
The conflict is real and should be decided in the open.
- One variant, one deadline, one actor. A single percentage discount for a campaign that ends Friday does not need a registry of policies. A package-private class used by
PlaceOrderis cohesive enough. Record the reopening condition: a second discount whose rules finance will edit independently, or a test suite that cannot constructPlaceOrderwithout SMTP. Until then, the interface is a tax. - Speculative open-closed. A plugin model for "any future promotion" freezes the workflow. Product changes the workflow, and every plugin breaks. You paid for extension along the wrong axis and lost the ability to modify the axis you actually touch.
- LSP by bloating the contract. To make a strange subtype fit, the team weakens the interface until every method returns a nullable optional status and every caller defends itself. Substitution is formally restored and the policy is gone. Prefer a different type, not a looser parent.
- Interface segregation as file count. Thirty interfaces with one call site each slow review and navigation. Segregate when a client is forced to depend on unused behavior, not when a diagram looks unfashionably simple.
- Dependency inversion of the domain vocabulary. Abstractions around
Money,Sku, andClock(the JDKClockis already the right seam for time) are enough. Abstractions around addition are not.
The architectural move is the same as lesson 1: write the decision. "We did not introduce DiscountPolicy because only WELCOME exists. The day a second policy must ship without editing placement, we extract the interface and run the contract suite." That sentence is SOLID practiced by adults. A mandate to extract on contact with a noun is SOLID practiced as cargo.
What this lesson will not make you do
It will not introduce a generic result wrapper, a pipeline framework, or a base order class "for reuse." Those are how SRP dies in a utility package. It will not replace the design-pattern course. If you later name the discount interface a strategy, do it in module 2, after the contract exists. The name is optional. The contract is not.
Architecture and Design Diagrams
Actors map to types. The arrows are "is allowed to call," and they are the coupling you are willing to defend.
mermaidflowchart LRproduct[Product owner: placement rules]finance[Finance: discounts]support[Support: receipt wording]operator[Operator: storage]place[PlaceOrder]order[Order]policy[DiscountPolicy]store[OrderStore]mail[ReceiptSender]product --- placeproduct --- orderfinance --- policysupport --- mailoperator --- storeplace --> orderplace --> policyplace --> storeplace --> mail
PlaceOrder depends on two interfaces and on Order. It does not depend on JDBC or SMTP. Finance can add a PercentageDiscount without editing the sender. Support can edit a template without editing the discount math. That is the modifiability claim, drawn as absence of arrows.
The runtime sequence after the split shows the connascence of execution in one method: persist, then send, and a send failure does not pretend the order was unplaced. The order is the system of record. The receipt is a best-effort notice, which matches the entitlement-versus-email decision in lesson 1.
mermaidsequenceDiagramparticipant Clientparticipant Place as PlaceOrderparticipant Policy as DiscountPolicyparticipant Store as OrderStoreparticipant Mail as ReceiptSenderClient->>Place: place(cart, coupon)Place->>Policy: discount(subtotal, coupon)Policy-->>Place: amount within contractPlace->>Store: save(placed order)Store-->>Place: okPlace->>Mail: send(receipt)Mail-->>Place: ok or logged failurePlace-->>Client: order id
A subtype that throws on an unknown coupon breaks this sequence at the policy call: the order is not saved, and the caller sees an error for a condition the contract defined as zero discount. The diagram assumes the contract. The contract suite is what keeps the diagram true when someone registers a new @Bean.
Spring's composition sits outside the policy. The source dependency points inward.
mermaidflowchart TBconfig[Spring configuration]jdbc[JdbcOrderStore]smtp[SmtpReceiptSender]place[PlaceOrder]policy[DiscountPolicy]config --> jdbcconfig --> smtpconfig --> placejdbc --> storeIface[OrderStore]smtp --> mailIface[ReceiptSender]place --> storeIfaceplace --> mailIfaceplace --> policy
JdbcOrderStore depends on the interface. PlaceOrder depends on the interface. Neither of those two depends on the other. Configuration is the only place that sees both concretes. That is dependency inversion as a picture. Lesson 5 scales the same picture up to packages and forbids the domain package from importing Spring at all. This lesson still allows PlaceOrder to be a @Service if the team wants a single module. The trade-off is noted in the walkthrough: the annotation is a framework import in the policy type. Prefer a plain class and a @Bean method when you can, so the policy module does not need spring-context on the compilation classpath. The sample below uses the plain class.
Technical Explanation
The discount contract
The contract is the architecture of the variation point. Implementations are replaceable only as far as the contract reaches.
| Clause | Why a caller cares |
|---|---|
| Same currency as the subtotal | A silent currency change corrupts the charge |
| Zero to subtotal, inclusive | A negative discount raises the price; an excessive one creates a credit the ledger does not have |
| Unknown coupon yields zero | Product treats typos as "no offer," not as a failed checkout |
| No I/O | The quote and place paths stay inside the latency budget from lesson 2 |
A policy that needs a remote eligibility service is a different abstraction, with a timeout in the contract, or it is a detail hidden behind a local decision that was already fetched. Stuffing it into DiscountPolicy fails LSP and the latency scenario together.
Persistence and mail are not one gateway
OrderStore.save either commits a placed order or throws. It does not send mail. ReceiptSender.send may fail. PlaceOrder catches that failure, logs the order id, and still returns the id. Swallowing a store failure would be the opposite bug: the learner is told the order exists and the row does not. The two failure modes belong to two actors. One interface would force one error policy.
Idempotency of placement is a driver when a client retry can double-charge. This lesson accepts an orderId generated by the caller and treats a second save of the same id as a conflict the store reports. It does not build an inbox table. That design is lesson 37. Mentioning the limit keeps the class from growing a second responsibility called "reliability framework."
Spring as the composition mechanism
Spring Boot 3 wires the graph with @Configuration and @Bean, or with stereotype annotations and component scan. Both are real. Component scan is shorter and hides the graph. An explicit @Bean method is easier to review against the diagram, and it keeps PlaceOrder free of annotations. Use scan when the module is small and the stereotypes are obvious. Use explicit beans when the review is about dependency direction.
Clock is injected for the same reason as the store. A test supplies Clock.fixed. Production supplies Clock.systemUTC(). You do not invent a TimeProvider interface unless you need a behavior Clock cannot express. The JDK type is already the abstraction.
Transactions: if save must be atomic with an entitlement insert, the boundary is @Transactional on a method that runs both writes, using Spring's transaction manager around a JDBC connection. Putting @Transactional on DiscountPolicy would be a category error. Putting it on the whole PlaceOrder.place method also wraps the receipt send in the transaction, which holds a database connection during SMTP. That is a coupling bug with an availability cost. The walkthrough commits in the store, then sends. If entitlement and order must share a transaction, they share a store method, not a mail call.
Testing the contract, not the bean
A JUnit 5 test class written against DiscountPolicy runs every implementation, including future ones a developer adds in a @Bean. Parameterized tests are the usual tool (@ParameterizedTest with @MethodSource). The architectural rule is social as much as technical: reviewers do not merge a policy bean that is not in the source list. ArchUnit, used more heavily in lesson 5, can check that every class implementing DiscountPolicy lives in the pricing package. It cannot check the arithmetic. The contract test checks the arithmetic.
Anemic models and service locators
Two neighboring failure modes deserve a paragraph because they arrive dressed as SOLID.
An anemic Order with only accessors, and a OrderManager that does the work, often concentrates every actor in the manager. SRP is not restored by moving methods out of the noun. Put invariants in Order: an empty cart cannot be placed, a line quantity is at least one, all lines share a currency. Put orchestration in PlaceOrder. Put variable money rules in the policy.
A service locator (ApplicationContext.getBean inside PlaceOrder) inverts nothing. The policy still names concrete types or magic strings, and the test cannot see the dependencies from the constructor. Constructor injection is the form that makes the coupling visible in the signature. Visibility is the point.
Practical Implementation or Design Walkthrough
Problem
Refactor course-order placement so finance can change a discount rule, support can change receipt text, and the operator can change storage, without those edits landing in one class. Do it only at the seams the actors require. The charge itself stays behind the capture path from lesson 2; this walkthrough starts after the amount is known and ends at a placed order plus a receipt. There is one live discount, WELCOME, ten percent, and a recorded decision not to build a promotion engine.
Domain types
Each public type is its own file. They are gathered here so the contracts read in order. Money is minimal: one currency, non-negative, scale left to the caller. It is not a money library. If you later need rounding rules per currency, that is a finance change to this type, which is the correct single responsibility.
javapackage com.techlearningpro.orders;import java.math.BigDecimal;import java.math.RoundingMode;import java.util.Objects;public record Money(BigDecimal amount, String currency) {public Money {Objects.requireNonNull(amount, "amount");Objects.requireNonNull(currency, "currency");if (currency.isBlank() || amount.signum() < 0) {throw new IllegalArgumentException("currency and non-negative amount are required");}amount = amount.setScale(2, RoundingMode.HALF_UP);}public Money plus(Money other) {requireSameCurrency(other);return new Money(amount.add(other.amount), currency);}public Money minus(Money other) {requireSameCurrency(other);if (other.amount.compareTo(amount) > 0) {throw new IllegalArgumentException("underflow");}return new Money(amount.subtract(other.amount), currency);}private void requireSameCurrency(Money other) {if (!currency.equals(other.currency)) {throw new IllegalArgumentException("currency mismatch");}}}public record Sku(String value) {public Sku {if (value == null || value.isBlank()) {throw new IllegalArgumentException("sku");}}}public record OrderLine(Sku sku, int quantity, Money unitPrice) {public OrderLine {Objects.requireNonNull(sku, "sku");Objects.requireNonNull(unitPrice, "unitPrice");if (quantity < 1) {throw new IllegalArgumentException("quantity");}}public Money lineTotal() {return new Money(unitPrice.amount().multiply(BigDecimal.valueOf(quantity)), unitPrice.currency());}}
Order holds the invariant that a placed order has an id, a total after discount, and at least one line. It does not know how the discount was chosen.
javapackage com.techlearningpro.orders;import java.time.Instant;import java.util.ArrayList;import java.util.List;import java.util.Objects;public record OrderId(String value) {public OrderId {if (value == null || value.isBlank()) {throw new IllegalArgumentException("order id");}}}public final class Order {private final OrderId id;private final List<OrderLine> lines;private final Money discount;private final Instant placedAt;private Order(OrderId id, List<OrderLine> lines, Money discount, Instant placedAt) {this.id = id;this.lines = List.copyOf(lines);this.discount = discount;this.placedAt = placedAt;}public static Order place(OrderId id, List<OrderLine> lines, Money discount, Instant placedAt) {Objects.requireNonNull(placedAt, "placedAt");if (lines == null || lines.isEmpty()) {throw new IllegalArgumentException("an order needs a line");}var currency = lines.getFirst().unitPrice().currency();var subtotal = new Money(java.math.BigDecimal.ZERO, currency);for (var line : lines) {subtotal = subtotal.plus(line.lineTotal());}if (discount.amount().compareTo(subtotal.amount()) > 0) {throw new IllegalArgumentException("discount exceeds subtotal");}return new Order(id, new ArrayList<>(lines), discount, placedAt);}public OrderId id() { return id; }public List<OrderLine> lines() { return lines; }public Money discount() { return discount; }public Instant placedAt() { return placedAt; }public Money total() {var subtotal = new Money(java.math.BigDecimal.ZERO, discount.currency());for (var line : lines) {subtotal = subtotal.plus(line.lineTotal());}return subtotal.minus(discount);}}
The policy and one implementation
javapackage com.techlearningpro.orders;public interface DiscountPolicy {/*** Discount to subtract from subtotal.* Same currency, zero through subtotal inclusive.* An unrecognized coupon yields zero. No I/O.*/Money discount(Money subtotal, String couponCode);}public final class WelcomePercentDiscount implements DiscountPolicy {private final String code;public WelcomePercentDiscount(String code) {if (code == null || code.isBlank()) {throw new IllegalArgumentException("code");}this.code = code;}@Overridepublic Money discount(Money subtotal, String couponCode) {if (couponCode == null || !couponCode.equalsIgnoreCase(code)) {return new Money(java.math.BigDecimal.ZERO, subtotal.currency());}var raw = subtotal.amount().multiply(new java.math.BigDecimal("0.10"));return new Money(raw, subtotal.currency());}}
Ten percent is the campaign. Changing the rate is a modification of this class, which is correct: there is one campaign. A second campaign with different rules should be a new class implementing the same contract, registered beside this one, or a small composite that tries policies in order. Do not edit PlaceOrder to branch on the code.
Store, sender, and the use case
javapackage com.techlearningpro.orders;public interface OrderStore {void save(Order order);}public interface ReceiptSender {void send(OrderId id, Money total);}public final class PlaceOrder {private final OrderStore orders;private final DiscountPolicy discounts;private final ReceiptSender receipts;private final java.time.Clock clock;public PlaceOrder(OrderStore orders, DiscountPolicy discounts,ReceiptSender receipts, java.time.Clock clock) {this.orders = java.util.Objects.requireNonNull(orders);this.discounts = java.util.Objects.requireNonNull(discounts);this.receipts = java.util.Objects.requireNonNull(receipts);this.clock = java.util.Objects.requireNonNull(clock);}public OrderId place(OrderId id, java.util.List<OrderLine> lines, String coupon) {var currency = lines.getFirst().unitPrice().currency();var subtotal = new Money(java.math.BigDecimal.ZERO, currency);for (var line : lines) {subtotal = subtotal.plus(line.lineTotal());}var discount = discounts.discount(subtotal, coupon);var order = Order.place(id, lines, discount, clock.instant());orders.save(order);try {receipts.send(order.id(), order.total());} catch (RuntimeException ex) {// The order row is the record. Receipt failure is support's replay path.System.err.println("receipt failed for " + order.id().value());}return order.id();}}
The System.err line is a stand-in for the platform logger (org.slf4j.Logger, which Spring Boot configures via Logback). In the repository you would inject no logger at all and use a static LoggerFactory.getLogger(PlaceOrder.class), which is the SLF4J API Spring Boot already provides. The catch boundary is the design: receipt failure is not an order failure. A reviewer who "simplifies" by letting the exception abort the HTTP request after save has created a client retry that can double-place unless the store rejects the duplicate id. The store's conflict behavior has to be part of the driver's acceptance. For this slice, save throwing on a duplicate id is the contract, and the client keeps the id it sent.
JDBC detail, outside the policy
JdbcClient has been in Spring Framework since 6.1 and is the JDBC style Boot 3.2 applications use for straightforward SQL. The store depends on OrderStore, not the other way around. The SQL is the operator's reason to change.
javapackage com.techlearningpro.orders.jdbc;import com.techlearningpro.orders.Order;import com.techlearningpro.orders.OrderStore;import org.springframework.jdbc.core.simple.JdbcClient;public final class JdbcOrderStore implements OrderStore {private final JdbcClient jdbc;public JdbcOrderStore(JdbcClient jdbc) {this.jdbc = jdbc;}@Overridepublic void save(Order order) {jdbc.sql("insert into course_order (id, total_amount, currency, placed_at) values (:id, :total, :currency, :placedAt)").param("id", order.id().value()).param("total", order.total().amount()).param("currency", order.total().currency()).param("placedAt", java.sql.Timestamp.from(order.placedAt())).update();}}
Line items belong in a child table written in the same transaction as the parent. That method should be @Transactional on the store implementation or on a single application method that only calls the store, not on ReceiptSender. The snippet shows the parent insert because that is enough to see the dependency direction. A production store that inserts only the parent is incomplete at the schema level; it is not a license to move SQL into PlaceOrder.
Spring configuration
javapackage com.techlearningpro.orders.config;import com.techlearningpro.orders.DiscountPolicy;import com.techlearningpro.orders.PlaceOrder;import com.techlearningpro.orders.ReceiptSender;import com.techlearningpro.orders.WelcomePercentDiscount;import com.techlearningpro.orders.jdbc.JdbcOrderStore;import java.time.Clock;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.jdbc.core.simple.JdbcClient;@Configurationclass OrderBeans {@BeanClock clock() {return Clock.systemUTC();}@BeanDiscountPolicy welcomeDiscount() {return new WelcomePercentDiscount("WELCOME");}@BeanJdbcOrderStore orderStore(JdbcClient jdbc) {return new JdbcOrderStore(jdbc);}@BeanPlaceOrder placeOrder(JdbcOrderStore orders, DiscountPolicy discounts,ReceiptSender receipts, Clock clock) {return new PlaceOrder(orders, discounts, receipts, clock);}}
ReceiptSenderAdapter is the SMTP or provider implementation of ReceiptSender. It is ordinary Java. The provider's client library stays inside that class. PlaceOrder does not import it.
Contract test
javaimport static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;import java.math.BigDecimal;import org.junit.jupiter.api.Test;class DiscountContractTest {private final DiscountPolicy policy = new WelcomePercentDiscount("WELCOME");@Testvoid unknownCouponDiscountsNothing() {var subtotal = new Money(new BigDecimal("100.00"), "USD");assertDoesNotThrow(() -> policy.discount(subtotal, "NOPE"));assertEquals(new BigDecimal("0.00"), policy.discount(subtotal, "NOPE").amount());}@Testvoid welcomeTakesTenPercent() {var subtotal = new Money(new BigDecimal("100.00"), "USD");assertEquals(new BigDecimal("10.00"), policy.discount(subtotal, "welcome").amount());}}
Add every new @Bean policy to a @MethodSource when the second policy appears. That day is the day the interface has earned its keep. Until then, one test class on the one implementation is still the contract, and PlaceOrder depends on the interface so the test of placement can pass a stub that returns a fixed Money with no percent arithmetic at all.
Deferred abstraction, recorded
Decision: do not build a stack of combinable promotions. Alternative rejected: a rules engine. Consequence: a second independent discount requires a code change at the policy, not at the HTTP boundary, and a campaign that needs stacking waits for an explicit reopen. Evidence to reopen: finance schedules two concurrent codes whose rules differ and whose bugs must ship on separate days. Stakeholder who pays today: the squad, in exchange for a place method a new engineer can read.
Enterprise Real-World Scenarios
The order class that owned four actors
TechLearningPro's first course checkout put pricing, coupon branches, a JDBC insert, and a templated email in CheckoutService. The modifiability scenario from the tax work said a VAT change should be a one-module diff. The actual diff for a receipt wording change touched the same class as the VAT change, so the two reviews blocked each other and the riskier one (tax) rode along with the safer one (copy). A receipt outage also rolled back the mental model of the on-call engineer: the exception flew out of the same method as a failed insert, and the dashboard counted both as checkout errors. Availability of placement and availability of mail had been fused by a class boundary.
The refactor followed the actors, not a mandate to create eight packages. DiscountPolicy and WelcomePercentDiscount took the coupon. Order took the invariant. JdbcOrderStore took SQL. ReceiptSender took the template. PlaceOrder became orchestration with the failure split shown above. The VAT change the following month touched the policy and its contract test. The receipt change touched the sender. They shipped on different days. That is the only success metric this story claims.
What the squad refused during the refactor matters as much. They did not introduce a generic "checkout pipeline" of stages. They did not invert Money. They did not split OrderStore into reader and writer until a second client (a support lookup) appeared and was forced to depend on save. ISP waited for a client. The unused read method was not speculative. It was absent.
The week a one-line rate change crossed twelve files
A later promotion, "friends of the institute," was expected to be a second percentage. Two engineers, freshly back from a principles workshop, built a policy registry, a stacking combinator, a priority annotation, and a Spring List<DiscountPolicy> injection. The combinator called every policy and summed discounts, which quietly violated the contract that a discount does not exceed the subtotal once two policies both returned ten percent. Nothing in production had two policies. The only live code path still applied WELCOME.
When finance asked to move WELCOME from ten percent to fifteen for a weekend, the change required understanding the registry order, the annotation, and a combinator test that failed for an unrelated rounding interaction. The diff crossed twelve files. The modifiability measure in the scenario was one working day and one module. The design missed it because the variation point was fiction.
The repair was deletion. One class, one rate, the contract test, and a decision record with the reopen condition. The fifteen-percent weekend shipped as a modification of WelcomePercentDiscount, reviewed by finance's stakeholder in the pull request because the class was small enough to show them. SOLID was reintroduced the month a second, non-percentage rule (a fixed amount for a scholarship sku) arrived with its own calendar. The new class implemented DiscountPolicy. The contract suite gained a second argument in the method source. PlaceOrder did not change. That is open-closed paid for on the day the driver exists, which is the practice this course means by the principle.
Best Practices
- Name the actor before you extract a type. If you cannot name a person who would request the change, keep the code together.
- Put invariants in the type they protect. Leave orchestration in the use case. Leave variable arithmetic in a policy whose contract you can print on one screen.
- Write the LSP contract as tests against the interface. Register no Spring bean that is not on that list.
- Keep I/O out of policies that quote and place both call, unless the contract documents the I/O, the timeout, and the failure response.
- Inject abstractions through constructors. Let Spring's
@Beanmethods be the place that sees JDBC and the mail client. - Do not wrap a receipt send in the same transaction as the order insert.
- Prefer a concrete class when there is one variant. Record the reopen condition next to the class.
- Use
Clockfor time. Do not add a time abstraction of your own. - When a principle adds a type, name the scenario whose diff gets smaller. If no diff gets smaller, remove the type.
- Review failure behavior per actor. A store failure and a mail failure are different product events.
- Let /learn/design-patterns supply pattern names after the contract is real. Do not start from the name.
- Keep package dependencies aligned with the arrows in the diagram. A policy class that imports the JDBC store has undone DIP regardless of how many interfaces sit beside it.
Common Mistakes and Anti-Patterns
The god class. Pricing, persistence, and mail in one type, often grown "just one more if." Actors block each other's releases, and failure metrics collapse. The repair is a split along actors, with orchestration left in one use case that shows the order of calls.
SRP as one method per class. A swarm of types with no invariant anywhere, and a manager that knows everything. Navigation cost goes up and cohesion of the business rule goes down. The repair is to put the rule back in Order or in the policy and delete the pass-through objects.
Open-closed along an imaginary axis. Plugin frameworks, registries, and stacking for a single percentage. The real change, a rate edit, gets harder. The repair is the decision record and a concrete class until the second variant is scheduled.
LSP violated by a convenient throw. UnknownCouponException for a typo, when the contract promised zero. Callers grow defensive catches, and a substituted test double that obeys the contract behaves differently from production. The repair is the contract suite, and a subtype that returns zero. If product later wants typos to be errors, that is a contract change, owned by every caller, not a private surprise in one class.
The fat gateway. save, sendMail, refund, and search on one interface so "orders are together." Tests become heavy and unused methods grow empty bodies in doubles. The repair is segregation by client. Keep methods together only when the clients move together.
Inversion of everything. Interfaces for money, for string normalization, for the use case's every line. The policy module depends on a forest of factories and still imports Spring to find them. The repair is to invert the details that have I/O or multiple variants, and to depend openly on the domain vocabulary.
Transactional mail. @Transactional wrapped around a method that inserts and then calls a provider. The connection sits busy, and a slow provider looks like database unavailability. The repair is the sequence in the diagram: commit the order, then send, and treat send failure as its own scenario.
Troubleshooting and Design Review
Symptom: A copy change and a tax change are always in the same pull request.
Likely cause: one class still serves both actors, or the policy calls the sender.
Investigation: open the type that contains the tax rate and search for template strings or SMTP types.
Corrective action: extract on the actor boundary. If the shared class is PlaceOrder and it only orchestrates, the leak is a call that passes a template into the policy. Move the template to the sender.
Symptom: Checkout errors spike when the mail provider slows, and orders are missing.
Likely cause: the exception from ReceiptSender aborts the request after a commit, the client retries with a new id, or the insert was inside a transaction that rolled back on the mail exception and the client still displayed success. Both have been seen.
Investigation: the catch boundary and the transaction boundary. Confirm whether save commits before send.
Corrective action: the walkthrough's split. Make duplicate client ids a store conflict so a retry is safe. Do not "fix" this by sending mail before the insert; a sent receipt for an uncommitted order is a worse stakeholder outcome.
Symptom: Adding a scholarship discount changes PlaceOrder and the controller.
Likely cause: coupon kind is a switch in the use case. The variation point was never extracted, or it was extracted and then bypassed.
Investigation: search for the coupon code outside the policy package.
Corrective action: a new DiscountPolicy and a contract test. If two policies must combine, write the combination rule as its own type with a postcondition on the sum, and do it when the second policy is real.
Symptom: Policy tests boot Spring and a database.
Likely cause: DIP is incomplete. The policy constructs a store, or the test uses @SpringBootTest out of habit for a pure function.
Investigation: the constructor parameters of the policy. A pure discount method should need none of Spring.
Corrective action: a JUnit test that news the policy. Reserve Spring tests for the @Bean wiring and the JDBC store.
Symptom: Twelve files change when a constant changes.
Likely cause: speculative open-closed structure from the second story. Registries, annotations, and combinators with one implementation.
Investigation: count classes that mention the coupon, and ask which scheduled stakeholder needs the second variant.
Corrective action: delete the unused machinery. Record the reopen condition. Treat the deletion as the architecture fix, not as a step backward.
Review questions: Who is the actor for this new type? What contract must a substitute pass? Which client is forced to see methods it does not call? Does any policy import JDBC, SMTP, or Spring? Where is the transaction relative to the receipt? If there is only one implementation, where is the written decision to keep it concrete or to extract?
Interview Questions and Answers
Q1. What does single responsibility mean when someone says a class "does one thing"?
I use the actor reading: one reason to change, where the reason is a stakeholder who would ask. An order class that enforces "no empty placement" and "discount cannot exceed subtotal" has one actor even if the methods are numerous. A class that prices and sends mail has two, and those edits will block each other. I do not split a cohesive rule across a manager and an anemic object to make the files shorter. Size is not the principle.
Q2. When is a class open-closed in a way you would accept in review?
When there is a named variation point with a contract, and a new variant is a new type that passes that contract, without an edit to the callers. A VAT or coupon family that finance will extend is a fair variation point. "The checkout flow" usually is not, because product still modifies the flow itself. I also accept ordinary modification for bug fixes and for the single live variant. Open-closed is not a ban on editing source.
Q3. Describe an LSP violation that unit tests against the concrete class would miss.
A DiscountPolicy contract says an unknown coupon returns a zero discount and does not throw. The concrete WelcomePercentDiscount obeys it. A new StrictCouponPolicy throws UnknownCouponException. Tests written only against StrictCouponPolicy pass. PlaceOrder, written to the interface, now fails a checkout on a typo and never reaches save. The contract suite parameterized over every registered implementation catches it. I treat that suite as part of the architecture, not as optional cleanup.
Q4. How do you decide the granularity of an interface?
I look at clients. Methods used together by the same clients stay together. A client that must implement empty methods, or a test that must stub mail in order to save an order, means the interface is too wide and should be split. A cluster of one-method interfaces with a single caller is usually segregation done for the diagram. OrderStore and ReceiptSender are split because their failure behavior and their actors differ, not because two is a magic number.
Q5. What is dependency inversion in a Spring service, as opposed to "we use Spring"?
High-level policy depends on abstractions it owns; details depend on those abstractions; a composition root wires them. Constructor parameters of type OrderStore and ReceiptSender, with JdbcOrderStore implementing the interface in another package, meet the principle. @Autowired on a concrete JDBC class, or ApplicationContext.getBean, does not. Spring is a reasonable composition root. It is not the inversion. I want PlaceOrder to be constructible in a JUnit test with hand-written doubles and no application context.
Q6. Where should @Transactional sit relative to a receipt email, and why?
Around the database writes that must commit together, and not around the network call to the mail provider. A transaction held open during SMTP occupies a connection and turns mail latency into database pool latency. If the exception rolls the transaction back, the learner may have seen a success path that then disappears, or the opposite confusion if the mail already went out. I commit the order, then send, and I define a receipt failure as a replayable support event keyed by order id.
Q7. When would you refuse to apply SOLID even though a reviewer asked for it?
When there is a single variant, a near deadline, and no second actor blocked by the current class. I keep a concrete WelcomePercentDiscount and write the condition that reopens the decision: a second scheduled rule that must ship independently. I refuse plugin registries, stacking combinators, and interfaces for Money in that same review. The cost of the extra types is a missed modifiability measure on the change we actually get, which is often a one-line rate edit. I have deleted that structure in a real checkout; the deletion was the fix.
Q8. How does SOLID relate to the modifiability scenarios in an architecture review?
The scenario names the diff that must stay small: tax rules, not the vendor adapter; receipt copy, not the discount. SOLID is one way to arrange types so that diff is small. If the scenario does not exist, the principles have nothing to aim at and they over-abstract. If the scenario exists and the VAT rate is still a constant inside the controller, the principles have not been applied where they pay. I would score the design against the diff, not against a checklist of five letters.
Q9. What is connascence doing in an answer about SOLID?
It names the strength of a coupling SOLID is trying to weaken. Sharing a Money type is mild and appropriate. Requiring every caller to remember that mail is sent only after save, and only by catching a particular runtime exception, is connascence of execution and of algorithm. I collapse that into PlaceOrder so there is one place to get it right. I do not pretend the coupling can be deleted. Placement really does depend on storage succeeding first.
Q10. How do you prevent a subtype from doing I/O behind a policy interface?
I write "no I/O" into the contract and the test (the test would fail if a stub server were required, and review forbids network types in the policy package). A policy that must consult a remote eligibility service is a different port with a timeout and a defined failure, composed before the pure discount, or it is not a DiscountPolicy. Substituting it for a pure policy weakens the latency postcondition the quote path assumed. LSP here is the quality attribute, not only the type checker. The compiler will not notice a HTTP client. The package rule and the contract text must.
Q11. What does constructor injection give you that field injection does not?
A compile-time and test-time list of dependencies. The object cannot be created half-wired. PlaceOrder in a unit test is new PlaceOrder(store, policy, sender, clock) with obvious doubles. Field injection hides the list and encourages a service locator habit. Spring supports constructor injection as the recommended style, and a single constructor does not even need @Autowired on modern Spring. I would reject a policy class whose collaborators are set after construction.
Q12. How would you answer "is SOLID an architecture"?
No. It is a set of object-level constraints that help a module meet a modifiability or testability scenario once the architecture has named the actors and the variation points. It does not decide service boundaries, data ownership, or consistency. A SOLID codebase can still share a database transaction with a mail provider or trust a client-supplied tenant id. Those are the driver failures from lessons 1–3. I use SOLID inside the boundary those lessons drew, and I stop when the next extraction does not shrink a real diff.
Hands-On Exercises
Exercise: Refactor a tightly coupled order-placement service
Problem statement. You are given a single CheckoutService that computes a ten-percent welcome discount, inserts an order with JDBC, and sends a receipt through a mail client. Refactor it along stakeholder lines. Keep one live discount. Do not introduce a promotion engine.
Tasks.
- List the actors and the reason each would change the code.
- Extract types so a receipt-template change and a discount-rate change are different files. Show the
PlaceOrdersequence, including failure behavior. - Write a
DiscountPolicycontract and a test that an unknown coupon yields zero and does not throw. - Show a Spring
@Configurationthat wires a JDBC store and a sender withoutPlaceOrderimporting those classes. - State where
@Transactionalgoes, and what must not be inside it. - Add one bad subtype in a test and show the assertion it fails.
- Write the decision that defers a second abstraction you were tempted to build, including the reopen condition.
- Note one security or privacy concern in the receipt path (what the email must not contain).
Expected deliverables. The types (compileable Java), the contract test, the configuration sketch, the failure-sequence note, and the deferred-abstraction decision.
Evaluation rubric.
| Assessment area | Weight | What "good" looks like |
|---|---|---|
| Actor split | 20% | Tax, placement invariants, storage, and receipt can change separately |
| Contract and LSP | 25% | A substitute that throws or over-discounts fails a shared test |
| Dependency direction | 20% | Policy types do not import JDBC or the mail client |
| Failure behavior | 15% | A mail failure does not erase a committed order; a store failure does not send a receipt |
| Judgment under schedule | 10% | A speculative abstraction is refused in writing |
| Operational sense | 10% | Transactions do not cover the provider call |
Copying WelcomePercentDiscount from this lesson is fine. Copying a twelve-class registry is a miss even if every class has a single method.
Lesson Summary
SOLID constrains reasons to change, substitution contracts, client-sized interfaces, and the direction of dependencies. Underneath the letters are cohesion, coupling, and the practical question of which actor owns a diff. In the course-order path, those actors are finance, placement, storage, and the receipt. The use case orchestrates. The policy computes. The store commits. The sender runs after the commit and is allowed to fail on its own.
The same principles become a delivery problem when they are applied to variations that are not scheduled. One welcome rate wants a class, a contract test, and a written reopen condition, not a combinator. Lesson 5 lifts dependency direction from objects to packages: the dependency rule of clean architecture, mapped onto Spring Boot without letting the framework into the entity.
Further Reading
- Robert C. Martin, Clean Architecture — the SOLID chapters, read together with the dependency rule in lesson 5. His actor formulation of SRP is the one used here.
- Bertrand Meyer, Object-Oriented Software Construction — the open-closed principle in its original setting.
- Barbara Liskov and Jeannette Wing, "A Behavioral Notion of Subtyping" — the contract rules behind LSP.
- Martin Fowler, Anemic Domain Model — the failure mode that looks like many small services and holds no invariant.
- Spring Framework, IoC and dependency injection — constructor injection as the composition mechanism, not the principle.
- Pattern-course primers, after the contract exists: /learn/design-patterns/solid-overview, /learn/design-patterns/dependency-inversion, /learn/design-principles.
- This course: /learn/advanced-system-design/architectural-thinking-and-quality-attributes, /learn/advanced-system-design/clean-architecture, /learn/advanced-system-design/dependency-injection-and-inversion-of-control.