Design Patterns & SOLID
Two programs can print exactly the same output, yet one is a pleasure to change and the other breaks every time someone touches it. The difference is design: how responsibilities are split between classes and how those classes depend on each other. When a client in Pune asks for a new payment method or a new tax rule, good design means adding a class, not rewriting ten.
This lesson covers the SOLID principles, five guidelines for object-oriented design, and the design patterns you will meet most often in Java codebases and interviews. A design pattern is a named, reusable solution to a common design problem. Many were catalogued in the 1994 book Design Patterns by four authors, often called the "Gang of Four". Modern Java (interfaces, lambdas, records, enums) makes most of them much shorter than the original versions.
The SOLID principles
| Letter | Principle | In plain words |
|---|---|---|
| S | Single responsibility | A class should have one reason to change. |
| O | Open/closed | Add new behaviour by adding code, not by editing working code. |
| L | Liskov substitution | A subclass must work anywhere its parent type is expected. |
| I | Interface segregation | Prefer several small interfaces over one large one. |
| D | Dependency inversion | Depend on interfaces, and receive dependencies from outside. |
Imagine an InvoiceService that calculates GST, writes to MySQL and sends SMS, all inside one 300-line method. A change to the SMS provider, the tax rule or the database forces edits to the same class, and testing it needs a real database and a real phone. Here is the same job split along SOLID lines (the 18% rate is only an example; real GST rates depend on the item, so confirm them with a CA):
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
public class Solid {
record Invoice(String customer, BigDecimal amount) {}
// Small interfaces, each with one job
interface TaxPolicy { BigDecimal taxOn(BigDecimal net); }
interface InvoiceStore { void save(Invoice invoice); List<Invoice> all(); }
interface Sender { void send(String to, String text); }
static class FlatGst implements TaxPolicy {
private final BigDecimal rate;
FlatGst(String rate) { this.rate = new BigDecimal(rate); }
public BigDecimal taxOn(BigDecimal net) {
return net.multiply(rate).setScale(2, RoundingMode.HALF_UP);
}
}
static class InMemoryStore implements InvoiceStore {
private final List<Invoice> saved = new ArrayList<>();
public void save(Invoice invoice) { saved.add(invoice); }
public List<Invoice> all() { return List.copyOf(saved); }
}
static class ConsoleSender implements Sender {
public void send(String to, String text) { System.out.println("[to " + to + "] " + text); }
}
// Depends only on interfaces, which are handed in from outside
static class InvoiceService {
private final TaxPolicy tax;
private final InvoiceStore store;
private final Sender sender;
InvoiceService(TaxPolicy tax, InvoiceStore store, Sender sender) {
this.tax = tax;
this.store = store;
this.sender = sender;
}
Invoice bill(String customer, BigDecimal net) {
Invoice invoice = new Invoice(customer, net.add(tax.taxOn(net)));
store.save(invoice);
sender.send(customer, "Your invoice total is Rs " + invoice.amount());
return invoice;
}
}
public static void main(String[] args) {
InvoiceStore store = new InMemoryStore();
Sender sender = new ConsoleSender();
InvoiceService domestic = new InvoiceService(new FlatGst("0.18"), store, sender);
domestic.bill("Kabir", new BigDecimal("1000.00"));
domestic.bill("Priya", new BigDecimal("2499.00"));
TaxPolicy zeroRated = net -> BigDecimal.ZERO.setScale(2);
InvoiceService export = new InvoiceService(zeroRated, store, sender);
export.bill("Overseas client", new BigDecimal("5000.00"));
System.out.println("Invoices saved: " + store.all().size());
}
}[to Kabir] Your invoice total is Rs 1180.00 [to Priya] Your invoice total is Rs 2948.82 [to Overseas client] Your invoice total is Rs 5000.00 Invoices saved: 3
Each principle is visible in the code:
- S: tax maths, storage and messaging live in separate classes, so each changes for one reason only.
- O: a zero-rated export invoice needed a new
TaxPolicy(here a one-line lambda), not an edit toInvoiceService. - L: any
InvoiceStoremust honour the contract "what you save,all()returns". A store that silently drops invoices would compile but break every caller, which is exactly what Liskov forbids. - I:
Senderhas one method. A class that only sends SMS is not forced to implement email, push and WhatsApp methods it does not need. - D:
InvoiceServicenever writesnew MySqlStore(); it receives its dependencies through the constructor. In tests you pass fakes or Mockito mocks (Mocking with Mockito); in production, Spring Boot wires the real ones.
Strategy, Factory and Observer
These three patterns appear in almost every business application.
- Strategy: a family of interchangeable algorithms behind one interface, such as delivery charges, discounts or payment methods. With functional interfaces, each strategy can be a lambda.
- Factory: one method decides which implementation to create, often from configuration or user input, so callers do not need
ifchains. - Observer: an object publishes events, and any number of listeners react without the publisher knowing who they are. It is the idea behind event listeners, message queues and Spring's application events.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class Patterns {
// Strategy: interchangeable algorithms behind one interface
interface DeliveryCharge { int forKm(int km); }
// Factory: one place decides which implementation to create
static DeliveryCharge chargeFor(String plan) {
return switch (plan) {
case "standard" -> km -> km <= 5 ? 0 : 40;
case "express" -> km -> 60 + km * 5;
case "prime" -> km -> 0;
default -> throw new IllegalArgumentException("Unknown plan: " + plan);
};
}
// Observer: listeners react to an event; the publisher does not know who they are
record OrderPlaced(String id, String customer, int total) {}
static class OrderEvents {
private final List<Consumer<OrderPlaced>> listeners = new ArrayList<>();
void subscribe(Consumer<OrderPlaced> listener) { listeners.add(listener); }
void publish(OrderPlaced event) { listeners.forEach(l -> l.accept(event)); }
}
public static void main(String[] args) {
for (String plan : List.of("standard", "express", "prime")) {
DeliveryCharge charge = chargeFor(plan);
System.out.println(plan + ": 3 km = Rs " + charge.forKm(3) + ", 12 km = Rs " + charge.forKm(12));
}
OrderEvents events = new OrderEvents();
events.subscribe(e -> System.out.println("SMS to " + e.customer() + ": order " + e.id() + " confirmed"));
events.subscribe(e -> System.out.println("Warehouse: pack order " + e.id()));
events.subscribe(e -> {
if (e.total() >= 5000) {
System.out.println("Fraud check queued for " + e.id());
}
});
events.publish(new OrderPlaced("A-101", "Meera", 1299));
events.publish(new OrderPlaced("A-102", "Rohan", 7499));
}
}standard: 3 km = Rs 0, 12 km = Rs 40 express: 3 km = Rs 75, 12 km = Rs 120 prime: 3 km = Rs 0, 12 km = Rs 0 SMS to Meera: order A-101 confirmed Warehouse: pack order A-101 SMS to Rohan: order A-102 confirmed Warehouse: pack order A-102 Fraud check queued for A-102
Adding a fraud check did not touch the order code at all; it simply subscribed. When strategies need their own data or many methods, write them as classes, or as an enum whose constants implement the interface. When the set of variants is fixed and known, a sealed interface with records and a pattern-matching switch (see Sealed Classes & Pattern Matching) is often clearer than a strategy.
Builder and Adapter
A Builder constructs an object with many optional parts step by step, instead of a constructor with eight parameters where nobody remembers which true means what. You use builders constantly: HttpRequest.newBuilder(), JsonMapper.builder(), StringBuilder. An Adapter wraps an existing class with an inconvenient interface so it fits the interface your code expects, which is common when integrating an old library or a vendor SDK.
import java.util.Objects;
public class Tickets {
// Builder: readable construction of an object with many optional parts
static final class TrainTicket {
private final String passenger;
private final String from;
private final String to;
private final String travelClass;
private final boolean meal;
private final String berth;
private TrainTicket(Builder b) {
passenger = Objects.requireNonNull(b.passenger, "passenger is required");
from = Objects.requireNonNull(b.from, "from is required");
to = Objects.requireNonNull(b.to, "to is required");
travelClass = b.travelClass;
meal = b.meal;
berth = b.berth;
}
static Builder builder() { return new Builder(); }
@Override
public String toString() {
return passenger + " " + from + " to " + to + " [" + travelClass
+ ", meal=" + meal + ", berth=" + berth + "]";
}
static final class Builder {
private String passenger;
private String from;
private String to;
private String travelClass = "SL"; // sensible defaults
private boolean meal = false;
private String berth = "any";
Builder passenger(String v) { passenger = v; return this; }
Builder from(String v) { from = v; return this; }
Builder to(String v) { to = v; return this; }
Builder travelClass(String v) { travelClass = v; return this; }
Builder meal(boolean v) { meal = v; return this; }
Builder berth(String v) { berth = v; return this; }
TrainTicket build() { return new TrainTicket(this); }
}
}
// Adapter: make an old API fit the interface the rest of the code expects
interface Sender { void send(String phone, String text); }
static class OldSmsLibrary { // third-party code we cannot change
int sendMessage(String text, String mobileWithCountryCode, int priority) {
System.out.println("OLD LIB -> " + mobileWithCountryCode + " (p" + priority + "): " + text);
return 0; // 0 means success in this old API
}
}
static class OldSmsAdapter implements Sender {
private final OldSmsLibrary library = new OldSmsLibrary();
public void send(String phone, String text) {
int code = library.sendMessage(text, "+91" + phone, 1);
if (code != 0) {
throw new IllegalStateException("SMS failed with code " + code);
}
}
}
public static void main(String[] args) {
TrainTicket ticket = TrainTicket.builder()
.passenger("Ananya").from("Lucknow").to("Pune")
.travelClass("3A").berth("lower")
.build();
System.out.println(ticket);
System.out.println(TrainTicket.builder().passenger("Farhan").from("Kochi").to("Chennai").build());
try {
TrainTicket.builder().passenger("Isha").build();
} catch (NullPointerException e) {
System.out.println("Rejected: " + e.getMessage());
}
Sender sender = new OldSmsAdapter();
sender.send("9810000001", "Your PNR is confirmed");
}
}Ananya Lucknow to Pune [3A, meal=false, berth=lower] Farhan Kochi to Chennai [SL, meal=false, berth=any] Rejected: from is required OLD LIB -> +919810000001 (p1): Your PNR is confirmed
The builder gives defaults for optional fields and checks required ones in one place, and the finished ticket is immutable. The adapter hides three oddities of the old library (argument order, country code, numeric error codes) behind a clean Sender, so if you later switch SMS providers, only the adapter changes. For simple data with few fields, a record is better than a builder.
Singleton, and patterns to use carefully
A Singleton guarantees exactly one instance of a class. If you truly need one, the simplest safe form in Java is an enum with a single constant: enum AppConfig { INSTANCE; ... }. The JVM creates it once, lazily and thread-safely. However, singletons accessed through a static getInstance() are global state in disguise: hard to replace in tests and easy to misuse. In modern applications, a dependency-injection framework creates one shared instance of each service for you (Spring beans are singletons by default), and your classes simply receive it through their constructors.
Patterns are a vocabulary, not a checklist. Adding a factory, a builder and three interfaces to a 50-line program makes it worse. Apply a pattern when you feel the pain it solves: repeated if chains over types (Strategy), constructors with too many parameters (Builder), an awkward third-party API (Adapter), or one change rippling through many classes (Observer).
Common mistakes
- God classes. A class named
Manager,UtilorHelperwith 40 methods usually breaks single responsibility; split it by job. - Creating dependencies inside a class.
new MySqlStore()in a service hard-wires it; pass it through the constructor. - Subclasses that throw
UnsupportedOperationExceptionfor inherited methods. That breaks Liskov; rethink the hierarchy or split the interface. - Static singletons everywhere. They make tests share state; use dependency injection.
- Pattern fever. Do not add patterns where a plain method or a record is enough.
Key points
- SOLID: single responsibility, open/closed, Liskov substitution, interface segregation, dependency inversion.
- Depend on small interfaces and receive implementations through constructors; this makes code testable and replaceable.
- Strategy swaps algorithms; Factory centralises creation; Observer lets listeners react to events.
- Builder handles many optional parameters; Adapter wraps an awkward API; Decorator adds behaviour around an object with the same interface.
- Use an enum for a true singleton, but prefer dependency injection.
- Use patterns to solve real problems, not to decorate simple code.
Exercise
Write a Decorator called SafeSender that implements Sender (void send(String phone, String text)) and wraps any other Sender. It must block messages longer than 160 characters (printing a "Blocked" line instead of sending) and count how many messages it actually passed on. Test it by wrapping a ConsoleSender and sending three messages, one of them 200 characters long, then print the count.
Show solution
public class Decorate {
interface Sender { void send(String phone, String text); }
static class ConsoleSender implements Sender {
public void send(String phone, String text) {
System.out.println("[to " + phone + "] " + text);
}
}
// Decorator: same interface, wraps another Sender and adds behaviour
static class SafeSender implements Sender {
private static final int MAX_LENGTH = 160;
private final Sender inner;
private int sent = 0;
SafeSender(Sender inner) { this.inner = inner; }
public void send(String phone, String text) {
if (text.length() > MAX_LENGTH) {
System.out.println("Blocked: message to " + phone + " has " + text.length() + " characters");
return;
}
inner.send(phone, text);
sent++;
}
int sentCount() { return sent; }
}
public static void main(String[] args) {
SafeSender sender = new SafeSender(new ConsoleSender());
sender.send("9810000001", "Your order A-101 is out for delivery");
sender.send("9810000002", "x".repeat(200));
sender.send("9810000003", "Your refund of Rs 499 has been processed");
System.out.println("Messages sent: " + sender.sentCount());
}
}[to 9810000001] Your order A-101 is out for delivery Blocked: message to 9810000002 has 200 characters [to 9810000003] Your refund of Rs 499 has been processed Messages sent: 2
Because SafeSender is itself a Sender, code that expects a Sender cannot tell the difference, and decorators can be stacked: new SafeSender(new OldSmsAdapter()) would add the same checks to the old SMS library. Java's own I/O classes use this pattern, for example a BufferedReader wrapping another Reader.
Quick Quiz
1. Which SOLID principle says "add new behaviour by adding code, not by editing working code"?
A new TaxPolicy implementation extends the system without changing InvoiceService.
2. A checkout needs different delivery-charge rules for different plans. Which pattern fits best?
Strategy puts interchangeable algorithms behind one interface.
3. What problem does the Builder pattern solve?
A builder names each part, supplies defaults and validates before creating the object.
4. Why is dependency injection usually preferred over static singletons?
Injected dependencies are explicit and swappable; static singletons are hidden global state.
Learn Java with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
