SOLID Principles — The Foundation of Clean Code
2026-03-23javasoliddesign-principlesoopclean-code
1. Why SOLID Matters
SOLID is a set of five object-oriented design principles popularized by Robert C. Martin (“Uncle Bob”) in the early 2000s—especially through Agile Software Development, Principles, Patterns, and Practices (2002) and later Clean Architecture (2017). They are not algorithms; they are constraints on how modules depend on each other so that systems remain understandable as requirements churn.
Historical anchor: Barbara Liskov’s behavioral notion of subtyping (1987 keynote, later formalized) predates the “L” in SOLID, but SOLID bundles that idea with cohesion, extension points, interface shaping, and dependency direction—making it a practical checklist for everyday Java and Spring code.
The cost of ignoring SOLID (orders of magnitude)
Empirical studies vary by domain, but multiple industry reports (DORA Accelerate, Google’s State of DevOps) correlate low change failure rate and high deployment frequency with small batches, automated testing, and loose coupling. Violating SOLID does not guarantee failure—but it predictably pushes teams toward the wrong tail of those distributions.
| Evidence lens | What “bad OO shape” tends to do | Representative magnitude (industry surveys / experience reports) |
|---|---|---|
| Change failure rate | Risky edits ripple through shared blobs | Elite performers often report <15% change failure vs ~30–45% for low performers |
| Lead time for changes | Fear + manual regression cycles | Elite <1 day median vs weeks for legacy coupling traps |
| Mean time to restore | Hard-to-localize defects | Hours vs days when boundaries are muddy |
| Test automation ROI | Concrete new and god classes resist doubles | Teams report 2–5× longer test stabilization for tightly coupled modules |
Treat the numbers as directional, not guarantees: your compiler will still pass while your delivery curve flattens.
SOLID vs STUPID (a memorable anti-checklist)
| Letter | STUPID smell | What it destroys | SOLID counterweight |
|---|---|---|---|
| S | Singleton as global state | Testability, clarity of lifecycle | Prefer scoped beans (Spring) / explicit composition |
| T | Tight coupling | Safe refactors | DIP, ISP, smaller surfaces |
| U | Untestability | Fast feedback | DIP + SRP to isolate IO |
| P | Premature optimization | Readable structure | OCP via clear extension points—not clever micro-tweaks everywhere |
| I | Indescriptive naming | Reasoning at a glance | Names reflect roles (SRP, ISP) |
| D | Duplication | Drift and inconsistency | Extract stable policies (OCP, SRP) |
SOLID is a design vocabulary, not a religion
Use SOLID to name forces (coupling, cohesion, substitutability) during code review. The goal is sustainable change, not maximal indirection.
Real-world analogy: the municipal permit office
Imagine one clerk who validates forms, collects fees, updates the ledger, emails applicants, and prints certificates. A zoning law tweak forces retraining that clerk, rewiring the printer integration, and re-testing email templates—one reason to change became five. SOLID asks you to separate those reasons so policy, persistence, notification, and presentation evolve independently.
Reference visuals (freely licensed diagrams)
Table: violation → symptom → cost
| Violation | Symptom you feel in Java/Spring | Approximate team cost |
|---|---|---|
| SRP | OrderService also builds PDFs and sends SMS | Every feature touches the same class; merge conflicts spike |
| OCP | switch (paymentType) across services | Adding PayLater means editing 6 call sites |
| LSP | Subclass throws UnsupportedOperationException | Polymorphism is a lie; production surprises |
| ISP | implements MegaRepository with 20 empty methods | Mocks are huge; clients know too much |
| DIP | new RestTemplate() inside domain service | Unit tests need network; profiles cannot swap implementations |
flowchart TB
subgraph chain["Coupling chain"]
A[God class / SRP breach] --> B[Switch on types / OCP breach]
B --> C[Leaky inheritance / LSP risk]
C --> D[Fat interfaces / ISP breach]
D --> E[Concrete dependencies / DIP breach]
end
E --> F[Fear of change]
F --> G[Slower releases]
G --> H[Heavier manual QA]
H --> I[Higher defect escape rate]flowchart LR
subgraph STUPID["STUPID pressures"]
S1[Singleton globals]
T1[Tight coupling]
U1[Untestable modules]
P1[Premature optimization]
I1[Poor naming]
D1[Duplication]
end
subgraph SOLID["SOLID responses"]
SR[Scoped lifecycle + cohesion]
TC[Interfaces + boundaries]
UT[DIP + test doubles]
PO[Measured profiling + OCP hot spots]
NM[SRP-named roles]
DP[DRY policies + templates]
end
S1 --> SR
T1 --> TC
U1 --> UT
P1 --> PO
I1 --> NM
D1 --> DPSOLID is not a performance checklist
Indirection can add nanoseconds to milliseconds of overhead; it buys maintainability and testability. Profile hot paths separately—do not “DIP” your innermost SIMD loop without evidence.
DORA metrics — where SOLID shows up indirectly
Teams with modular architecture and loose coupling report higher deployment frequency and lower change failure rate because small, testable units reduce batch size. SOLID does not replace CI/CD—it makes CI/CD honest by shrinking the blast radius of each commit.
sequenceDiagram
participant Client
participant Before as CheckoutService (switch)
participant After as CheckoutService (strategy)
participant New as BNPLPaymentMethod
Client->>Before: pay(BNPL)
Before-->>Client: recompile entire module
Client->>After: pay(BNPL)
After->>New: charge()
New-->>After: PaymentResult
After-->>Client: OK (classpath extension)flowchart LR
subgraph core["Order context (SRP)"]
OAPI[Order API]
ODB[(Order DB)]
OAPI --> ODB
end
subgraph pay["Payments context (SRP)"]
PAPI[Payments API]
PDB[(Payments DB)]
PAPI --> PDB
end
subgraph note["ISP at the edge"]
MOB[Mobile BFF]
ADM[Admin BFF]
end
MOB -->|narrow DTOs| OAPI
MOB -->|charge only| PAPI
ADM -->|reports + refunds| PAPI2. S — Single Responsibility Principle (SRP)
Definition: A module (class/component) should have one reason to change—where “reason” maps to a single actor/stakeholder concern (billing rules vs. persistence vs. notification).
Three real-world analogies
- Restaurant: The sommelier pairs wine; the chef cooks; the cashier settles the bill. If the chef also runs the card reader, menu price changes and payment processor outages both interrupt cooking—two actors, one role.
- Hospital triage: Triage stabilizes and routes; pharmacy dispenses; billing codes visits. Mixing them means a new ICD code format breaks emergency intake.
- Airport: Security screens; gate agents board; baggage tracks luggage. One super-agent would couple FAA rule changes to baggage carousel software upgrades.
BAD: God class — invoice + persistence + email + PDF + validation
package com.example.solid.srp.bad;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Objects;
/**
* Everything that could ever happen to an invoice lives here.
* Any change to PDF layout, SMTP, disk layout, or validation rules touches this type.
*/
public final class InvoiceEverythingService {
public record Invoice(String id, String customerId, BigDecimal amount, Instant createdAt) {
}
public Invoice createInvoice(String customerId, BigDecimal amount) {
Objects.requireNonNull(customerId, "customerId");
Objects.requireNonNull(amount, "amount");
if (amount.signum() <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
if (customerId.isBlank()) {
throw new IllegalArgumentException("customerId must not be blank");
}
return new Invoice("INV-" + System.nanoTime(), customerId, amount, Instant.now());
}
public void saveToDisk(Invoice invoice, Path directory) throws Exception {
Objects.requireNonNull(invoice, "invoice");
Objects.requireNonNull(directory, "directory");
Path file = directory.resolve(invoice.id() + ".txt");
String line = invoice.id() + "|" + invoice.customerId() + "|" + invoice.amount() + "|"
+ invoice.createdAt() + "\n";
Files.writeString(file, line);
}
public void emailCustomer(Invoice invoice, String smtpHost) {
System.out.println("EMAIL via " + smtpHost + " to customer " + invoice.customerId()
+ " for invoice " + invoice.id());
}
public byte[] renderPdf(Invoice invoice) {
String text = "Invoice " + invoice.id() + " amount=" + invoice.amount();
return text.getBytes(StandardCharsets.UTF_8);
}
}// Everything in InvoiceEverythingService — PDF + mail + disk + rulesGOOD: Split into five+ focused types — same behavior, independent axes of change
package com.example.solid.srp.good;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Objects;
record Invoice(String id, String customerId, BigDecimal amount, Instant createdAt) {
}
final class InvoiceValidator {
public void validateNew(String customerId, BigDecimal amount) {
Objects.requireNonNull(customerId, "customerId");
Objects.requireNonNull(amount, "amount");
if (customerId.isBlank()) {
throw new IllegalArgumentException("customerId must not be blank");
}
if (amount.signum() <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
}
}
final class InvoiceFactory {
public Invoice newInvoice(String customerId, BigDecimal amount) {
return new Invoice("INV-" + System.nanoTime(), customerId, amount, Instant.now());
}
}
final class InvoiceFileRepository {
public void save(Invoice invoice, Path directory) throws Exception {
Objects.requireNonNull(invoice, "invoice");
Objects.requireNonNull(directory, "directory");
Path file = directory.resolve(invoice.id() + ".txt");
String line = invoice.id() + "|" + invoice.customerId() + "|" + invoice.amount() + "|"
+ invoice.createdAt() + "\n";
Files.writeString(file, line);
}
}
interface MailSender {
void sendInvoiceNotice(Invoice invoice);
}
final class LoggingMailSender implements MailSender {
private final String smtpHost;
public LoggingMailSender(String smtpHost) {
this.smtpHost = Objects.requireNonNull(smtpHost, "smtpHost");
}
@Override
public void sendInvoiceNotice(Invoice invoice) {
System.out.println("EMAIL via " + smtpHost + " to customer " + invoice.customerId()
+ " for invoice " + invoice.id());
}
}
interface InvoiceDocumentRenderer {
byte[] render(Invoice invoice);
}
final class PlainTextInvoiceRenderer implements InvoiceDocumentRenderer {
@Override
public byte[] render(Invoice invoice) {
String text = "Invoice " + invoice.id() + " amount=" + invoice.amount();
return text.getBytes(StandardCharsets.UTF_8);
}
}
/** Thin façade: orchestrates collaborators without owning their policies. */
public final class InvoiceApplicationService {
private final InvoiceValidator validator = new InvoiceValidator();
private final InvoiceFactory factory = new InvoiceFactory();
private final InvoiceFileRepository repository = new InvoiceFileRepository();
private final MailSender mailSender;
private final InvoiceDocumentRenderer renderer;
public InvoiceApplicationService(MailSender mailSender, InvoiceDocumentRenderer renderer) {
this.mailSender = Objects.requireNonNull(mailSender, "mailSender");
this.renderer = Objects.requireNonNull(renderer, "renderer");
}
public Invoice issueAndPersist(String customerId, BigDecimal amount, Path directory) throws Exception {
validator.validateNew(customerId, amount);
Invoice invoice = factory.newInvoice(customerId, amount);
repository.save(invoice, directory);
mailSender.sendInvoiceNotice(invoice);
return invoice;
}
public byte[] downloadPdf(Invoice invoice) {
return renderer.render(invoice);
}
}classDiagram
class InvoiceApplicationService {
+issueAndPersist()
+downloadPdf()
}
class InvoiceValidator {
+validateNew()
}
class InvoiceFactory {
+newInvoice()
}
class InvoiceFileRepository {
+save()
}
class MailSender {
<<interface>>
+sendInvoiceNotice()
}
class InvoiceDocumentRenderer {
<<interface>>
+render()
}
InvoiceApplicationService --> InvoiceValidator
InvoiceApplicationService --> InvoiceFactory
InvoiceApplicationService --> InvoiceFileRepository
InvoiceApplicationService --> MailSender
InvoiceApplicationService --> InvoiceDocumentRendererSpring Boot: Controller vs Service vs Repository vs Event publisher
| Layer | SRP in words | Typical Spring stereotype |
|---|---|---|
| HTTP adaptation | Map JSON ↔ domain DTOs; status codes | @RestController |
| Use case / policy | One application workflow | @Service (keep thin) |
| Persistence | Translate aggregates ↔ storage | @Repository / Spring Data |
| Integration side-effects | Email, search index, analytics | @Component listeners or outbound adapters |
package com.example.solid.srp.spring;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.Objects;
@RestController
class InvoiceController {
private final InvoiceWorkflowService workflowService;
InvoiceController(InvoiceWorkflowService workflowService) {
this.workflowService = Objects.requireNonNull(workflowService, "workflowService");
}
@PostMapping("/api/invoices")
InvoiceResponse create(@RequestBody CreateInvoiceRequest request) {
Invoice invoice = workflowService.createInvoice(request.customerId(), request.amount());
return new InvoiceResponse(invoice.id(), invoice.customerId(), invoice.amount());
}
public record CreateInvoiceRequest(String customerId, BigDecimal amount) {
}
public record InvoiceResponse(String id, String customerId, BigDecimal amount) {
}
}
@Service
class InvoiceWorkflowService {
private final InvoiceRepository invoices;
private final ApplicationEventPublisher events;
InvoiceWorkflowService(InvoiceRepository invoices, ApplicationEventPublisher events) {
this.invoices = Objects.requireNonNull(invoices, "invoices");
this.events = Objects.requireNonNull(events, "events");
}
@Transactional
public Invoice createInvoice(String customerId, BigDecimal amount) {
Invoice inv = new Invoice("INV-" + System.nanoTime(), customerId, amount);
invoices.save(inv);
events.publishEvent(new InvoiceCreatedEvent(inv.id(), inv.customerId()));
return inv;
}
}
interface InvoiceRepository {
void save(Invoice invoice);
}
record InvoiceCreatedEvent(String invoiceId, String customerId) {
}
record Invoice(String id, String customerId, BigDecimal amount) {
}SRP is not “one public method per class”
Split until each type’s name and tests tell a coherent story. Nano-classes that exist only to satisfy a metric are SRP theater.
Testing advantage: isolated collaborators
| Concern | Test focus | Without SRP | With SRP |
|---|---|---|---|
| Validation | Boundary cases | Must stub disk + mail | Pure unit on InvoiceValidator |
| Persistence | File bytes | Must construct PDF + SMTP | InvoiceFileRepository alone |
| Recipient + idempotency | Entangled with PDF | MailSender fake |
Table: signs of SRP violation
| Sign | Example smell |
|---|---|
Class name ends with Util, Manager, Helper doing unrelated jobs | BusinessHelper mails + parses CSV + caches |
| Import section mixes IO, crypto, UI | javax.mail + java.awt + JDBC in one file |
| Git blame shows many teams | Finance + platform + growth all touching one type |
Test class needs 10 @Mock fields | Too many reasons under test |
Where SRP appears in the JDK
| API split | Why it reflects SRP |
|---|---|
java.nio.file.Path vs java.nio.file.Files | Path models location; Files performs filesystem operations |
java.io.Reader vs java.io.BufferedReader | Reader reads chars; BufferedReader adds buffering policy |
java.util.stream.Stream vs collectors | Stream describes sequence pipeline; Collectors encode terminal policies |
Common mistake: nano-classes
If splitting produces types that are never reused and only called from one other type, consider private static helpers or package-private collaborators until a second use case appears.
Refactor a god class safely
Start by extracting pure functions (validation, mapping). Next extract IO behind interfaces. Finally introduce a thin façade that matches your use case language.
Name responsibilities after business language
Rename types for actors (InvoiceMailer, InvoicePdfRenderer) instead of technical steps (Helper2). Good names make SRP violations obvious in code review.
Align Spring stereotypes
Map each collaborator to @Component / @Service / @Repository only after boundaries are clear—annotations should follow structure, not invent it.
3. O — Open/Closed Principle (OCP)
Definition: Types should be open for extension but closed for modification—you add new behavior primarily by introducing new types, not by editing a central switch.
Analogies
- USB-C port: Hosts do not redesign the motherboard when a new device appears; the port contract stays, devices plug in.
- Electrical outlet: Appliances depend on voltage/frequency standard, not on the power plant’s turbine vendor.
- Audio plugins: DAWs load VSTs; the host API is stable, plugins vary.
BAD: Giant switch for payment processing
package com.example.solid.ocp.bad;
import java.math.BigDecimal;
import java.util.Objects;
public final class CheckoutService {
public enum PaymentType {
CARD, PAYPAL, CRYPTO
}
public String charge(PaymentType type, BigDecimal amount, String accountRef) {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(amount, "amount");
if (amount.signum() <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
return switch (type) {
case CARD -> {
System.out.println("Stripe card charge " + amount);
yield "CARD_OK";
}
case PAYPAL -> {
System.out.println("PayPal charge " + amount);
yield "PAYPAL_OK";
}
case CRYPTO -> {
System.out.println("Coinbase charge " + amount);
yield "CRYPTO_OK";
}
};
}
}Adding Buy Now Pay Later forces editing CheckoutService and recompiling every caller—even untouched paths.
GOOD: Strategy + composite pipeline (extension via new classes)
package com.example.solid.ocp.good;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
interface PaymentMethod {
boolean supports(PaymentContext context);
PaymentResult charge(PaymentContext context);
}
record PaymentContext(String customerId, BigDecimal amount, String instrumentHint) {
public PaymentContext {
Objects.requireNonNull(customerId, "customerId");
Objects.requireNonNull(amount, "amount");
if (amount.signum() <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
}
}
record PaymentResult(String processorReference, String auditNote) {
}
final class CardPaymentMethod implements PaymentMethod {
@Override
public boolean supports(PaymentContext context) {
return "CARD".equalsIgnoreCase(context.instrumentHint());
}
@Override
public PaymentResult charge(PaymentContext context) {
String ref = "card-" + UUID.randomUUID();
System.out.println("Stripe card charge " + context.amount());
return new PaymentResult(ref, "card");
}
}
final class PayPalPaymentMethod implements PaymentMethod {
@Override
public boolean supports(PaymentContext context) {
return "PAYPAL".equalsIgnoreCase(context.instrumentHint());
}
@Override
public PaymentResult charge(PaymentContext context) {
String ref = "paypal-" + UUID.randomUUID();
System.out.println("PayPal charge " + context.amount());
return new PaymentResult(ref, "paypal");
}
}
/** New behavior = new class on classpath — no edit to existing methods. */
final class BuyNowPayLaterPaymentMethod implements PaymentMethod {
@Override
public boolean supports(PaymentContext context) {
return "BNPL".equalsIgnoreCase(context.instrumentHint());
}
@Override
public PaymentResult charge(PaymentContext context) {
String ref = "bnpl-" + UUID.randomUUID();
System.out.println("BNPL schedule for " + context.amount());
return new PaymentResult(ref, "bnpl");
}
}
/**
* Composite: ordered chain; first supporting method wins.
* Could be replaced with Spring's List<PaymentMethod> injection.
*/
final class CompositePaymentMethod implements PaymentMethod {
private final List<PaymentMethod> delegates;
public CompositePaymentMethod(List<PaymentMethod> delegates) {
this.delegates = List.copyOf(Objects.requireNonNull(delegates, "delegates"));
}
@Override
public boolean supports(PaymentContext context) {
return delegates.stream().anyMatch(d -> d.supports(context));
}
@Override
public PaymentResult charge(PaymentContext context) {
return delegates.stream()
.filter(d -> d.supports(context))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No payment method for context"))
.charge(context);
}
}
public final class CheckoutService {
private final PaymentMethod paymentMethod;
public CheckoutService(PaymentMethod paymentMethod) {
this.paymentMethod = Objects.requireNonNull(paymentMethod, "paymentMethod");
}
public PaymentResult checkout(PaymentContext context) {
if (!paymentMethod.supports(context)) {
throw new IllegalArgumentException("Unsupported payment context");
}
return paymentMethod.charge(context);
}
public static void demo() {
List<PaymentMethod> chain = new ArrayList<>();
chain.add(new CardPaymentMethod());
chain.add(new PayPalPaymentMethod());
chain.add(new BuyNowPayLaterPaymentMethod());
CheckoutService service = new CheckoutService(new CompositePaymentMethod(chain));
service.checkout(new PaymentContext("cust-1", new BigDecimal("42.00"), "BNPL"));
}
}flowchart TB
CS[CheckoutService]
PM[(PaymentMethod)]
CS --> PM
PM --> Card[CardPaymentMethod]
PM --> PP[PayPalPaymentMethod]
PM --> BNPL[BuyNowPayLaterPaymentMethod]
PM --> Comp[CompositePaymentMethod]Spring Boot: @ConditionalOnProperty, auto-configuration, profiles
| Mechanism | OCP angle |
|---|---|
@ConditionalOnClass / @ConditionalOnProperty | Enable beans without editing core config when libraries/properties appear |
@Profile("stripe") | Swap whole implementation graphs per environment |
spring.factories / AutoConfiguration | Framework extends via classpath, not patching Spring’s source |
package com.example.solid.ocp.spring;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class PaymentAutoConfiguration {
@Bean
@ConditionalOnProperty(name = "payments.provider", havingValue = "stripe")
PaymentGateway stripeGateway() {
return new StripePaymentGateway();
}
@Bean
@ConditionalOnProperty(name = "payments.provider", havingValue = "paypal")
PaymentGateway paypalGateway() {
return new PayPalPaymentGateway();
}
}
interface PaymentGateway {
String charge(String customerId, String amount);
}
final class StripePaymentGateway implements PaymentGateway {
@Override
public String charge(String customerId, String amount) {
return "stripe:" + customerId + ":" + amount;
}
}
final class PayPalPaymentGateway implements PaymentGateway {
@Override
public String charge(String customerId, String amount) {
return "paypal:" + customerId + ":" + amount;
}
}Java ecosystem: ServiceLoader SPI
java.util.ServiceLoader loads implementations of an interface from META-INF/services—classic OCP on the JVM: core declares the contract, vendors ship jars that extend behavior.
OCP enables A/B testing and feature flags
| Technique | How OCP helps |
|---|---|
| Interface + two implementations | PricingPolicy with ControlPricingPolicy vs ExperimentPricingPolicy |
| Feature flag bean selection | @ConditionalOnProperty("feature.new-discount") picks implementation |
| Runtime composite | Decorator wrapping base policy with metrics |
OCP has a ceiling
If rules churn hourly and every variant is one line different, a rules engine or DSL may beat dozens of classes. OCP fights compile-time closure, not all dynamism.
Table: OCP-friendly patterns
| Pattern | When it shines | Spring-ish example |
|---|---|---|
| Strategy | Swappable algorithms | PaymentGateway beans |
| Template Method | Fixed skeleton, varying steps | JdbcTemplate callbacks |
| Decorator | Add cross-cutting behavior | @Transactional proxy layers |
| Visitor | Stable types, many operations | Rare in app code; compilers love it |
package com.example.solid.ocp.switchonly;
import java.math.BigDecimal;
public final class CheckoutService {
public String charge(String type, BigDecimal amount) {
return switch (type) {
case "CARD" -> "CARD_OK";
case "PAYPAL" -> "PAYPAL_OK";
default -> throw new IllegalArgumentException(type);
};
}
}# application.properties
pricing.policy=control
# pricing.policy=experimentA4. L — Liskov Substitution Principle (LSP)
Definition (behavioral subtyping): If S is a subtype of T, then objects of type T may be replaced with objects of type S without breaking correctness—subtypes must honor contracts of supertypes (preconditions, postconditions, invariants).
Historical note: Barbara Liskov introduced this view in her 1987 ACM SIGPLAN keynote (“Data abstraction and hierarchy”); SOLID’s “L” packages that idea for everyday OO.
Rectangle–Square problem — deep dive
Mathematically every square is a rectangle. In program state, if Rectangle is mutable and allows independent setWidth / setHeight, a Square that forces width == height changes observable behavior after substitution.
Proof sketch (failure scenario):
- Program
PacceptsRectangle rand executes:
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20;- Substitute
Square s. IfsetHeight(4)also forces width4,area()becomes16. Postcondition expected byPis violated.
Thus mutable squares are not behavioral subtypes of mutable rectangles under that contract.
BAD: Square extends Rectangle with coupled mutators
package com.example.solid.lsp.bad;
import java.util.Objects;
class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
if (width <= 0) {
throw new IllegalArgumentException("width must be positive");
}
this.width = width;
}
public void setHeight(int height) {
if (height <= 0) {
throw new IllegalArgumentException("height must be positive");
}
this.height = height;
}
public int area() {
return width * height;
}
}
final class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height);
}
}GOOD: Shared Shape interface + immutable value objects
package com.example.solid.lsp.good;
import java.util.Objects;
sealed interface Shape permits Rectangle, Square {
int area();
}
final class Rectangle implements Shape {
private final int width;
private final int height;
public Rectangle(int width, int height) {
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("dimensions must be positive");
}
this.width = width;
this.height = height;
}
@Override
public int area() {
return width * height;
}
public int width() {
return width;
}
public int height() {
return height;
}
}
final class Square implements Shape {
private final int side;
public Square(int side) {
if (side <= 0) {
throw new IllegalArgumentException("side must be positive");
}
this.side = side;
}
@Override
public int area() {
return side * side;
}
public int side() {
return side;
}
}classDiagram
class Shape {
<<interface>>
+area() int
}
class Rectangle {
-width int
-height int
+area() int
}
class Square {
-side int
+area() int
}
Shape <|.. Rectangle
Shape <|.. SquareSecond example: ReadOnlyList that throws on add
package com.example.solid.lsp.collections;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* Pretends to be a full List but rejects mutation — LSP landmine for API expecting List contract.
*/
public final class ReadOnlyList<T> extends ArrayList<T> {
@Override
public boolean add(T t) {
throw new UnsupportedOperationException("read-only");
}
@Override
public boolean addAll(Collection<? extends T> c) {
throw new UnsupportedOperationException("read-only");
}
}Better: expose List<T> as Collections.unmodifiableList or use interfaces like SequencedCollection views knowing mutators are optional—or model read with a narrower type.
Rules: preconditions, postconditions, invariants, history constraint
| Contract piece | Subtype must… | Rectangle/Square lesson |
|---|---|---|
| Precondition | Not strengthen (don’t require more from clients) | Square cannot reject widths heights rectangles accept without reason |
| Postcondition | Not weaken (guarantees stay true) | setWidth then setHeight must still yield expected area semantics |
| Invariant | Preserve class invariants | Both shapes keep positive dimensions |
| History constraint | Not enable states superclass forbade | Square cannot silently retune width when height changes if clients rely on independence |
flowchart TB
T[Supertype contract]
S[Subtype must be usable wherever T is expected]
T --> P[Pre: not stronger]
T --> Q[Post: not weaker]
T --> I[Invariants preserved]
T --> H[History constraints respected]
P --> S
Q --> S
I --> S
H --> SJDK examples
| API | LSP-positive observation |
|---|---|
InputStream subtypes | read returns -1 at EOS across streams |
ListIterator | Optional ops documented; UnsupportedOperationException documented for unmodifiable views |
Collections.unmodifiableList | Not a silent subtype for code assuming mutability—callers must respect optional operations |
JDK unmodifiable views vs LSP pedantry
Unmodifiable lists violate naive LSP if your method truly requires add. The fix is ISP + honest types (List vs immutable interfaces), not “catch UnsupportedOperationException everywhere.”
Spring: @Transactional and interface contracts
Spring proxies wrap your bean. If concrete methods are not visible through the injected interface, or final methods block proxying, transaction boundaries won’t apply—callers think they have T but behavior diverges. Honor the interface contract the client depends on.
Table: LSP rules with examples
| Rule | Java smell | Safer design |
|---|---|---|
| Don’t throw unexpected unchecked exceptions on overridden methods | clone()-style surprises | Narrow interface or document contract |
| Don’t strengthen preconditions | Subclass requires non-null where base allowed null | Validate at boundary once |
| Don’t return “impossible” values | Optional in override where base promised non-null | Align return types |
| Respect covariance sensibly | Overridden method returns subtype | Use bridges carefully |
5. I — Interface Segregation Principle (ISP)
Definition: Clients should not be forced to depend on methods they do not use—prefer small, role-specific interfaces over a fat one.
Analogies
- TV remote vs universal remote: Guests need volume + channel, not DVR scheduling + smart home macros.
- Swiss Army knife: Great pocket tool, poor scalpel—fine-grained surgery wants a single blade.
- Airline crew roles: Captain flies; purser manages cabin—you don’t ask the captain to restock peanuts.
BAD: Fat Printer interface
package com.example.solid.isp.bad;
import java.nio.file.Path;
interface OfficeMachine {
void print(String document);
void scanToPdf(Path output);
void fax(String number, String document);
void staple(int pages);
}
final class SimplePrinter implements OfficeMachine {
@Override
public void print(String document) {
System.out.println("PRINT " + document);
}
@Override
public void scanToPdf(Path output) {
throw new UnsupportedOperationException("no scanner");
}
@Override
public void fax(String number, String document) {
throw new UnsupportedOperationException("no fax");
}
@Override
public void staple(int pages) {
throw new UnsupportedOperationException("no stapler");
}
}GOOD: Role-specific interfaces
package com.example.solid.isp.good;
import java.nio.file.Path;
interface Printable {
void print(String document);
}
interface Scannable {
void scanToPdf(Path output);
}
interface Faxable {
void fax(String number, String document);
}
interface Stapling {
void staple(int pages);
}
final class SimplePrinter implements Printable {
@Override
public void print(String document) {
System.out.println("PRINT " + document);
}
}
final class MfpDevice implements Printable, Scannable, Faxable, Stapling {
@Override
public void print(String document) {
System.out.println("PRINT " + document);
}
@Override
public void scanToPdf(Path output) {
System.out.println("SCAN -> " + output);
}
@Override
public void fax(String number, String document) {
System.out.println("FAX " + number + " " + document);
}
@Override
public void staple(int pages) {
System.out.println("STAPLE " + pages);
}
}BAD 2: Fat repository
package com.example.solid.isp.badrepo;
import java.util.List;
import java.util.Optional;
interface MegaCustomerRepository {
void save(Customer c);
Optional<Customer> findById(String id);
List<Customer> searchByName(String q);
byte[] exportCsv();
void refreshWarehouse();
void runMonthlyReport();
}
final class CustomerService {
private final MegaCustomerRepository repo;
public CustomerService(MegaCustomerRepository repo) {
this.repo = repo;
}
public Customer register(Customer c) {
repo.save(c);
return c;
}
}CustomerService is forced to know about reporting and warehouse refresh through type dependency—even if it never calls those methods.
GOOD 2: Split ports
package com.example.solid.isp.goodrepo;
import java.util.List;
import java.util.Optional;
record Customer(String id, String name) {
}
interface CustomerWriteRepository {
void save(Customer c);
}
interface CustomerReadRepository {
Optional<Customer> findById(String id);
}
interface CustomerSearchRepository {
List<Customer> searchByName(String q);
}
interface CustomerReportingRepository {
byte[] exportCsv();
}
interface CustomerWarehouseSync {
void refreshWarehouse();
}
final class CustomerService {
private final CustomerWriteRepository writes;
private final CustomerReadRepository reads;
public CustomerService(CustomerWriteRepository writes, CustomerReadRepository reads) {
this.writes = writes;
this.reads = reads;
}
public Customer register(Customer c) {
writes.save(c);
return reads.findById(c.id()).orElseThrow();
}
}flowchart LR
subgraph fat["Fat MegaRepository"]
M1[save]
M2[find]
M3[search]
M4[csv]
M5[warehouse]
end
CS[CustomerService]
RS[ReportingService]
CS --> fat
RS --> fat
subgraph thin["Segregated ports"]
W[CustomerWriteRepository]
R[CustomerReadRepository]
S[CustomerSearchRepository]
C[CustomerReportingRepository]
end
CS2[CustomerService]
RP[ReportJob]
CS2 --> W
CS2 --> R
RP --> CSpring Data: CrudRepository vs JpaRepository vs custom fragments
| Interface | ISP trade-off |
|---|---|
Repository<T,ID> | Minimal marker—good when you want ultra-thin |
CrudRepository | Convenient but wide—pulls deleteAll, etc. |
JpaRepository | Fattest—flush, batch, queries—great for admin apps, heavy for domain services |
Interface fragments (CustomerRepository extends JpaRepository, CustomerCustomQueries) | Compose only what each bounded context needs |
Default methods (Java 8+) help ISP carefully
interface Readable { default void logRead() { ... } } can share code without forcing unrelated implementers—still keep surface area honest.
Table: when to split vs when to keep
| Split when… | Keep combined when… |
|---|---|
| Clients import unused methods mentally | Every implementation truly supports all ops |
| Tests require enormous fakes | The interface is already the stable domain port |
| Teams collide on unrelated features | Cohesion is high—SRP says one role |
Relationship: ISP and SRP
| Principle | Question it answers |
|---|---|
| SRP | “How many reasons does this class change?” |
| ISP | “How many methods does this client need to know?” |
A fat interface often signals multiple reasons bundled—fix ISP, and SRP often improves as a side effect.
6. D — Dependency Inversion Principle (DIP)
Definition:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
Analogy: wall outlet
Your laptop depends on 120V/60Hz or 230V/50Hz socket shape, not on coal vs nuclear generation. The standard is the abstraction; power plants are details.
BAD: Service constructs MySQL, Stripe, SMTP directly
package com.example.solid.dip.bad;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.util.Objects;
public final class OrderCheckoutService {
public void checkout(String orderId, BigDecimal amount, String customerEmail) throws Exception {
Objects.requireNonNull(orderId, "orderId");
Objects.requireNonNull(amount, "amount");
try (Connection c = DriverManager.getConnection("jdbc:mysql://localhost/demo", "u", "p")) {
PreparedStatement ps = c.prepareStatement("INSERT INTO payments(order_id, amount) VALUES (?,?)");
ps.setString(1, orderId);
ps.setBigDecimal(2, amount);
ps.executeUpdate();
}
System.out.println("Stripe charge " + amount);
System.out.println("SMTP to " + customerEmail);
}
}GOOD: Depend on interfaces; inject implementations
package com.example.solid.dip.good;
import java.math.BigDecimal;
import java.util.Objects;
interface PaymentLedger {
void recordPayment(String orderId, BigDecimal amount);
}
interface PaymentGateway {
String charge(BigDecimal amount, String orderId);
}
interface CustomerNotifier {
void notifyPaymentReceived(String customerEmail, String orderId);
}
public final class OrderCheckoutService {
private final PaymentLedger ledger;
private final PaymentGateway gateway;
private final CustomerNotifier notifier;
public OrderCheckoutService(PaymentLedger ledger, PaymentGateway gateway, CustomerNotifier notifier) {
this.ledger = Objects.requireNonNull(ledger, "ledger");
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.notifier = Objects.requireNonNull(notifier, "notifier");
}
public void checkout(String orderId, BigDecimal amount, String customerEmail) {
gateway.charge(amount, orderId);
ledger.recordPayment(orderId, amount);
notifier.notifyPaymentReceived(customerEmail, orderId);
}
}
final class InMemoryPaymentLedger implements PaymentLedger {
@Override
public void recordPayment(String orderId, BigDecimal amount) {
System.out.println("LEDGER " + orderId + " " + amount);
}
}
final class FakePaymentGateway implements PaymentGateway {
@Override
public String charge(BigDecimal amount, String orderId) {
return "fake-charge-" + orderId + "-" + amount;
}
}
final class LoggingCustomerNotifier implements CustomerNotifier {
@Override
public void notifyPaymentReceived(String customerEmail, String orderId) {
System.out.println("NOTIFY " + customerEmail + " for " + orderId);
}
}flowchart TB
subgraph app["Application (high-level)"]
OCS[OrderCheckoutService]
end
subgraph ports["Abstractions (ports)"]
PL[PaymentLedger]
PG[PaymentGateway]
CN[CustomerNotifier]
end
subgraph adapters["Details (adapters)"]
JDBC[JdbcPaymentLedger]
ST[StripeGateway]
SMTP[SmtpNotifier]
end
OCS --> PL
OCS --> PG
OCS --> CN
JDBC -.implements.-> PL
ST -.implements.-> PG
SMTP -.implements.-> CNFull Spring Boot example: @Autowired, @Configuration, @Profile, @Qualifier
package com.example.solid.dip.springdemo;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Objects;
interface PaymentGateway {
String charge(BigDecimal amount);
}
@Service
class CheckoutAppService {
private final PaymentGateway gateway;
CheckoutAppService(@Qualifier("primaryGateway") PaymentGateway gateway) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
}
public String pay(BigDecimal amount) {
return gateway.charge(amount);
}
}
@Configuration
class PaymentsConfig {
@Bean(name = "primaryGateway")
@Profile("prod")
PaymentGateway stripeGateway() {
return amount -> "stripe:" + amount;
}
@Bean(name = "primaryGateway")
@Profile("!prod")
PaymentGateway fakeGateway() {
return amount -> "fake:" + amount;
}
}Testing: Mockito makes DIP pay off
package com.example.solid.dip.test;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.math.BigDecimal;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CheckoutMockTest {
interface PaymentGateway {
String charge(BigDecimal amount);
}
static final class Checkout {
private final PaymentGateway gateway;
Checkout(PaymentGateway gateway) {
this.gateway = gateway;
}
String pay(BigDecimal amount) {
return gateway.charge(amount);
}
}
@Test
void usesInjectedDouble() {
PaymentGateway gw = Mockito.mock(PaymentGateway.class);
when(gw.charge(any())).thenReturn("ok");
Checkout c = new Checkout(gw);
org.junit.jupiter.api.Assertions.assertEquals("ok", c.pay(new BigDecimal("9.99")));
verify(gw).charge(new BigDecimal("9.99"));
}
}Hexagonal Architecture connection
| Hex term | DIP mapping |
|---|---|
| Port | Java interface your domain talks to |
| Adapter | implements port—JPA, HTTP client, message bus |
| Domain | OrderCheckoutService—no JDBC imports |
Table: injection styles
| Style | Pros | Cons |
|---|---|---|
| Constructor injection | Immutable, obvious requirements, test-friendly | Large constructors if abused |
| Setter injection | Optional/reconfigurable deps | Incomplete object risk |
| Method injection | Narrow scope (e.g., per-call User) | Verbose for ubiquitous deps |
DIP ≠ interfaces on every class
Introduce abstractions at volatility boundaries—vendor SDKs, IO, time, randomness, feature flags—not for StringUtils.
Spring Boot auto-configuration as “ultimate DIP”
Your @SpringBootApplication depends on ApplicationContext abstractions; concrete datasources, MVC, and actuator arrive via conditional beans—framework inverts construction to the container.
7. SOLID in Real Codebases
sequenceDiagram
participant C as Controller (HTTP SRP)
participant S as Service (use case SRP)
participant R as Repository (persistence SRP)
participant E as EventPublisher (integration SRP)
C->>S: command/DTO
S->>R: load/save aggregate
S->>E: publish domain event
S-->>C: response DTOHow Spring Framework uses SOLID internally
| Area | Observation |
|---|---|
BeanFactory / ApplicationContext | DIP container resolves abstractions |
HandlerAdapter, ViewResolver | OCP for new transports and view tech |
| AOP proxies | Decorator + LSP-sensitive method visibility |
Java Collections framework
| Principle | Example |
|---|---|
| SRP | Collections utility vs List interface |
| OCP | New SequencedCollection views without breaking List contract documentation |
| ISP | Queue vs List vs Set—different capabilities |
| LSP | SortedSet strengthens expectations—documented |
| DIP | APIs accept Collection/Map interfaces, not concrete HashMap in signatures |
SOLID in microservices
| Principle | Microservice angle |
|---|---|
| SRP | Service owns one bounded context |
| OCP | Versioned events/contracts; add consumers without editing producers’ core |
| LSP | Honor async message contracts—don’t silently drop required fields |
| ISP | GraphQL fields / protobuf packages segregate client views |
| DIP | Sidecars and discovery clients abstract transport |
Table: principle → framework pattern
| SOLID | Spring pattern | JDK pattern |
|---|---|---|
| SRP | @RestController vs @Service | Path vs Files |
| OCP | @ConditionalOn* | ServiceLoader SPI |
| LSP | Interface-based proxies | Collection optional ops |
| ISP | Repository fragments | Map vs SortedMap |
| DIP | Constructor injection | JDBC DataSource interface |
8. Common Mistakes & Anti-Patterns
flowchart TD
start[Something feels wrong] --> god{God class / huge file?}
god -->|yes| srp[SRP: split responsibilities]
god -->|no| switch{Switch on types / frequent edits?}
switch -->|yes| ocp[OCP: strategy or plugin point]
switch -->|no| throws{Subclass surprises / UnsupportedOp?}
throws -->|yes| lsp[LSP: fix model / composition]
throws -->|no| fat{Clients ignore interface methods?}
fat -->|yes| isp[ISP: narrow interfaces]
fat -->|no| news{new Concrete in domain?}
news -->|yes| dip[DIP: inject abstractions]
news -->|no| done[Measure / profile / revisit requirements]| Anti-pattern | Symptom | Fix |
|---|---|---|
| Over-abstraction | Types named AbstractFactoryBuilderProvider | Collapse until second use case |
| Interface-itis | UserService + UserServiceImpl with 1 impl forever | YAGNI—keep concrete until boundary needs swap |
| Naming theater | SomethingServiceImpl | Name after use case or policy |
| Cargo-cult patterns | Visitor for 3-line CSV parse | Simple function first |
| Leaky inheritance | extends HttpServlet for business rules | Delegate to domain services |
Spring + LSP + testing
When you inject concrete classes and rely on proxy-based @Transactional, you may think you have transactions but don’t. Prefer interface-based injection for transactional boundaries when using JDK proxies.
9. SOLID Cheat Sheet
Summary table
| Principle | One-liner | Primary tool |
|---|---|---|
| S | One reason to change | Extract class/module |
| O | Extend without editing core | Strategy/SPI/conditional beans |
| L | Subtypes honor contracts | Composition/immutable models |
| I | Small interfaces | Role interfaces / fragments |
| D | Depend on abstractions | Constructor injection / ports |
Interview questions with concise answers
| Question | Strong answer |
|---|---|
| Define SRP | “One actor’s concern per module—split validation, IO, orchestration.” |
| Define OCP | “Add classes, not switch arms, for new variants—Spring @Conditional / SPI.” |
| Rectangle vs Square | “Mutable square breaks independent width/height postconditions—use separate Shape types or immutability.” |
| Define ISP | “Clients shouldn’t depend on unused methods—split fat ports.” |
| Define DIP | “Policy depends on interfaces; infrastructure implements them—Spring DI.” |
| How does DIP help tests? | “Mock/stub ports without databases or HTTP.” |
Practical exercises
- Pick a 500+ line
@Servicefrom an open-source app; sketch SRP slices and write interfaces for IO only. - Replace one
switchon enum with strategy + Spring bean list ordered by@Order. - Model payments with immutable value types and prove LSP with unit tests that only use
PaymentMethodinterface. - Split a fat Spring Data repository into read/write interfaces consumed by different services.
- Implement a hexagonal module: domain jar has zero Spring imports; adapter module wires beans.
Attributions for diagrams
- UML class examples, dependency injection example, Clean Architecture core, Hexagonal Architecture, and UML interface realization diagrams are from Wikimedia Commons (various CC licenses; see file pages for details).
- Use them here for educational illustration; always retain license notices when redistributing outside this site.