SystemDesigndoc
LLD

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 lensWhat “bad OO shape” tends to doRepresentative magnitude (industry surveys / experience reports)
Change failure rateRisky edits ripple through shared blobsElite performers often report <15% change failure vs ~30–45% for low performers
Lead time for changesFear + manual regression cyclesElite <1 day median vs weeks for legacy coupling traps
Mean time to restoreHard-to-localize defectsHours vs days when boundaries are muddy
Test automation ROIConcrete new and god classes resist doublesTeams 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)

LetterSTUPID smellWhat it destroysSOLID counterweight
SSingleton as global stateTestability, clarity of lifecyclePrefer scoped beans (Spring) / explicit composition
TTight couplingSafe refactorsDIP, ISP, smaller surfaces
UUntestabilityFast feedbackDIP + SRP to isolate IO
PPremature optimizationReadable structureOCP via clear extension points—not clever micro-tweaks everywhere
IIndescriptive namingReasoning at a glanceNames reflect roles (SRP, ISP)
DDuplicationDrift and inconsistencyExtract 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)

UML class diagram example — relationships between classes and interfaces

Invoice domain as a UML class diagram — cohesive types around a single aggregate

Clean Architecture — dependency rule pointing inward (stable domain, volatile details)

Table: violation → symptom → cost

ViolationSymptom you feel in Java/SpringApproximate team cost
SRPOrderService also builds PDFs and sends SMSEvery feature touches the same class; merge conflicts spike
OCPswitch (paymentType) across servicesAdding PayLater means editing 6 call sites
LSPSubclass throws UnsupportedOperationExceptionPolymorphism is a lie; production surprises
ISPimplements MegaRepository with 20 empty methodsMocks are huge; clients know too much
DIPnew RestTemplate() inside domain serviceUnit tests need network; profiles cannot swap implementations
mermaid
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]
Coupling chain: each SOLID breach tightens the next link in the delivery chokehold
mermaid
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 --> DP
SOLID vs STUPID — replace each smell with a design move, not a buzzword

SOLID 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.

mermaid
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)
Before vs after OCP on the payment path: edit central switch vs plug in a new PaymentMethod bean
mermaid
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| PAPI
Microservice boundaries as SRP + ISP at system scale: each service exposes a narrow API surface

2. 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

  1. 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.
  2. Hospital triage: Triage stabilizes and routes; pharmacy dispenses; billing codes visits. Mixing them means a new ICD code format breaks emergency intake.
  3. 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

java
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);
    }
}
java
// Everything in InvoiceEverythingService — PDF + mail + disk + rules

GOOD: Split into five+ focused types — same behavior, independent axes of change

java
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);
    }
}
mermaid
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 --> InvoiceDocumentRenderer
SRP refactored: one façade orchestrates; each collaborator owns a single reason to change

Spring Boot: Controller vs Service vs Repository vs Event publisher

LayerSRP in wordsTypical Spring stereotype
HTTP adaptationMap JSON ↔ domain DTOs; status codes@RestController
Use case / policyOne application workflow@Service (keep thin)
PersistenceTranslate aggregates ↔ storage@Repository / Spring Data
Integration side-effectsEmail, search index, analytics@Component listeners or outbound adapters
java
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

ConcernTest focusWithout SRPWith SRP
ValidationBoundary casesMust stub disk + mailPure unit on InvoiceValidator
PersistenceFile bytesMust construct PDF + SMTPInvoiceFileRepository alone
EmailRecipient + idempotencyEntangled with PDFMailSender fake

Table: signs of SRP violation

SignExample smell
Class name ends with Util, Manager, Helper doing unrelated jobsBusinessHelper mails + parses CSV + caches
Import section mixes IO, crypto, UIjavax.mail + java.awt + JDBC in one file
Git blame shows many teamsFinance + platform + growth all touching one type
Test class needs 10 @Mock fieldsToo many reasons under test

Where SRP appears in the JDK

API splitWhy it reflects SRP
java.nio.file.Path vs java.nio.file.FilesPath models location; Files performs filesystem operations
java.io.Reader vs java.io.BufferedReaderReader reads chars; BufferedReader adds buffering policy
java.util.stream.Stream vs collectorsStream 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.

1

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.

2

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.

3

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

  1. USB-C port: Hosts do not redesign the motherboard when a new device appears; the port contract stays, devices plug in.
  2. Electrical outlet: Appliances depend on voltage/frequency standard, not on the power plant’s turbine vendor.
  3. Audio plugins: DAWs load VSTs; the host API is stable, plugins vary.

BAD: Giant switch for payment processing

java
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)

java
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&lt;PaymentMethod&gt; 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"));
    }
}
mermaid
flowchart TB
    CS[CheckoutService]
    PM[(PaymentMethod)]
    CS --> PM
    PM --> Card[CardPaymentMethod]
    PM --> PP[PayPalPaymentMethod]
    PM --> BNPL[BuyNowPayLaterPaymentMethod]
    PM --> Comp[CompositePaymentMethod]
OCP: stable CheckoutService depends on PaymentMethod abstraction; new processors are new classes

Spring Boot: @ConditionalOnProperty, auto-configuration, profiles

MechanismOCP angle
@ConditionalOnClass / @ConditionalOnPropertyEnable beans without editing core config when libraries/properties appear
@Profile("stripe")Swap whole implementation graphs per environment
spring.factories / AutoConfigurationFramework extends via classpath, not patching Spring’s source
java
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

TechniqueHow OCP helps
Interface + two implementationsPricingPolicy with ControlPricingPolicy vs ExperimentPricingPolicy
Feature flag bean selection@ConditionalOnProperty("feature.new-discount") picks implementation
Runtime compositeDecorator 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

PatternWhen it shinesSpring-ish example
StrategySwappable algorithmsPaymentGateway beans
Template MethodFixed skeleton, varying stepsJdbcTemplate callbacks
DecoratorAdd cross-cutting behavior@Transactional proxy layers
VisitorStable types, many operationsRare in app code; compilers love it
java
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);
        };
    }
}
properties
# application.properties
pricing.policy=control
# pricing.policy=experimentA

4. 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):

  1. Program P accepts Rectangle r and executes:
text
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20;
  1. Substitute Square s. If setHeight(4) also forces width 4, area() becomes 16. Postcondition expected by P is violated.

Thus mutable squares are not behavioral subtypes of mutable rectangles under that contract.

BAD: Square extends Rectangle with coupled mutators

java
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

java
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;
    }
}
mermaid
classDiagram
    class Shape {
        <<interface>>
        +area() int
    }
    class Rectangle {
        -width int
        -height int
        +area() int
    }
    class Square {
        -side int
        +area() int
    }
    Shape <|.. Rectangle
    Shape <|.. Square
LSP: BAD mutable inheritance breaks expectations; GOOD sealed Shape with distinct invariants

Second example: ReadOnlyList that throws on add

java
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 pieceSubtype must…Rectangle/Square lesson
PreconditionNot strengthen (don’t require more from clients)Square cannot reject widths heights rectangles accept without reason
PostconditionNot weaken (guarantees stay true)setWidth then setHeight must still yield expected area semantics
InvariantPreserve class invariantsBoth shapes keep positive dimensions
History constraintNot enable states superclass forbadeSquare cannot silently retune width when height changes if clients rely on independence
mermaid
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 --> S
LSP contract lattice: substitutable subtypes narrow allowed surprises for callers

JDK examples

APILSP-positive observation
InputStream subtypesread returns -1 at EOS across streams
ListIteratorOptional ops documented; UnsupportedOperationException documented for unmodifiable views
Collections.unmodifiableListNot 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

RuleJava smellSafer design
Don’t throw unexpected unchecked exceptions on overridden methodsclone()-style surprisesNarrow interface or document contract
Don’t strengthen preconditionsSubclass requires non-null where base allowed nullValidate at boundary once
Don’t return “impossible” valuesOptional in override where base promised non-nullAlign return types
Respect covariance sensiblyOverridden method returns subtypeUse 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

  1. TV remote vs universal remote: Guests need volume + channel, not DVR scheduling + smart home macros.
  2. Swiss Army knife: Great pocket tool, poor scalpel—fine-grained surgery wants a single blade.
  3. Airline crew roles: Captain flies; purser manages cabin—you don’t ask the captain to restock peanuts.

BAD: Fat Printer interface

java
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

java
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);
    }
}

UML interface realization — class implementing a small offered interface (ISP-friendly shape)

BAD 2: Fat repository

java
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

java
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();
    }
}
mermaid
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 --> C
ISP: fat interface forces all clients to depend on all roles; thin interfaces map to real needs

Spring Data: CrudRepository vs JpaRepository vs custom fragments

InterfaceISP trade-off
Repository<T,ID>Minimal marker—good when you want ultra-thin
CrudRepositoryConvenient but wide—pulls deleteAll, etc.
JpaRepositoryFattest—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 mentallyEvery implementation truly supports all ops
Tests require enormous fakesThe interface is already the stable domain port
Teams collide on unrelated featuresCohesion is high—SRP says one role

Relationship: ISP and SRP

PrincipleQuestion 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:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. 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

java
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

java
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);
    }
}

Dependency injection example — controllers and repositories wired through a manager (DIP in practice)

Hexagonal architecture — domain core with ports; adapters implement infrastructure

mermaid
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.-> CN
DIP: dependency arrows point toward abstractions; infrastructure implements ports

Full Spring Boot example: @Autowired, @Configuration, @Profile, @Qualifier

java
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

java
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 termDIP mapping
PortJava interface your domain talks to
Adapterimplements port—JPA, HTTP client, message bus
DomainOrderCheckoutServiceno JDBC imports

Table: injection styles

StyleProsCons
Constructor injectionImmutable, obvious requirements, test-friendlyLarge constructors if abused
Setter injectionOptional/reconfigurable depsIncomplete object risk
Method injectionNarrow 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

mermaid
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 DTO
Typical Spring request path annotated with SOLID roles (not strict package names)

How Spring Framework uses SOLID internally

AreaObservation
BeanFactory / ApplicationContextDIP container resolves abstractions
HandlerAdapter, ViewResolverOCP for new transports and view tech
AOP proxiesDecorator + LSP-sensitive method visibility

Java Collections framework

PrincipleExample
SRPCollections utility vs List interface
OCPNew SequencedCollection views without breaking List contract documentation
ISPQueue vs List vs Set—different capabilities
LSPSortedSet strengthens expectations—documented
DIPAPIs accept Collection/Map interfaces, not concrete HashMap in signatures

SOLID in microservices

PrincipleMicroservice angle
SRPService owns one bounded context
OCPVersioned events/contracts; add consumers without editing producers’ core
LSPHonor async message contracts—don’t silently drop required fields
ISPGraphQL fields / protobuf packages segregate client views
DIPSidecars and discovery clients abstract transport

Table: principle → framework pattern

SOLIDSpring patternJDK pattern
SRP@RestController vs @ServicePath vs Files
OCP@ConditionalOn*ServiceLoader SPI
LSPInterface-based proxiesCollection optional ops
ISPRepository fragmentsMap vs SortedMap
DIPConstructor injectionJDBC DataSource interface

8. Common Mistakes & Anti-Patterns

mermaid
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]
Decision tree: which principle to apply first when you smell trouble
Anti-patternSymptomFix
Over-abstractionTypes named AbstractFactoryBuilderProviderCollapse until second use case
Interface-itisUserService + UserServiceImpl with 1 impl foreverYAGNI—keep concrete until boundary needs swap
Naming theaterSomethingServiceImplName after use case or policy
Cargo-cult patternsVisitor for 3-line CSV parseSimple function first
Leaky inheritanceextends HttpServlet for business rulesDelegate 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

PrincipleOne-linerPrimary tool
SOne reason to changeExtract class/module
OExtend without editing coreStrategy/SPI/conditional beans
LSubtypes honor contractsComposition/immutable models
ISmall interfacesRole interfaces / fragments
DDepend on abstractionsConstructor injection / ports

Interview questions with concise answers

QuestionStrong answer
Define SRPOne actor’s concern per module—split validation, IO, orchestration.”
Define OCPAdd classes, not switch arms, for new variants—Spring @Conditional / SPI.”
Rectangle vs SquareMutable 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 DIPPolicy depends on interfaces; infrastructure implements them—Spring DI.”
How does DIP help tests?Mock/stub ports without databases or HTTP.”

Practical exercises

  1. Pick a 500+ line @Service from an open-source app; sketch SRP slices and write interfaces for IO only.
  2. Replace one switch on enum with strategy + Spring bean list ordered by @Order.
  3. Model payments with immutable value types and prove LSP with unit tests that only use PaymentMethod interface.
  4. Split a fat Spring Data repository into read/write interfaces consumed by different services.
  5. 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.
Free, no spam, unsubscribe anytime

Get every deep-dive in your inbox

New case studies, LLD patterns in Java, and HLD architectures on AWS — the full article delivered to you so you never miss a deep-dive.

Full case studies in emailJava + AWS ecosystemOne email per new article