What Are Design Patterns?
A design pattern is a named, reusable solution to a recurring structural problem in object-oriented design. It's not code you copy-paste — it's a template you adapt, and a shared vocabulary: saying "use a Strategy here" instantly communicates an entire approach to another developer without drawing a diagram.
The problem they solve: experienced developers kept independently arriving at the same solutions to the same recurring problems — swappable algorithms, one-time object creation, notifying multiple listeners of a change. The "Gang of Four" (Gamma, Helm, Johnson, Vlissides) catalogued 23 of these in 1994, giving the industry a common language that still holds today, even as the implementations have evolved with the language.
| Category | Concerned with | Patterns covered here |
|---|---|---|
| Creational | How objects are created | Singleton, Factory, Builder |
| Structural | How objects are composed | Adapter, Decorator, Facade |
| Behavioural | How objects interact | Observer, Strategy, Command |
Creational Patterns
Singleton — exactly one instance
Problem: you need exactly one shared instance of a class (connection pool, config). The correct thread-safe implementations are covered in depth in Synchronisation Mechanisms — the initialization-on-demand holder and the enum singleton. Quick reference:
// ✅ Enum singleton — simplest, thread-safe, serialization-safe by construction
public enum Database {
INSTANCE;
public void query(String sql) { /* ... */ }
}
Database.INSTANCE.query("SELECT 1");
// In Spring: you almost never write Singleton manually — the container
// manages bean scope. @Service / @Component beans are singletons by default.
Factory — delegate creation decisions
Problem: you need to create objects but the exact type isn't known until runtime, or you want callers decoupled from concrete classes.
interface Notifier { void send(String msg); }
class EmailNotifier implements Notifier {
@Override public void send(String msg) { /* SMTP */ }
}
class SmsNotifier implements Notifier {
@Override public void send(String msg) { /* SMS API */ }
}
// Classic factory: switch/if chain — works, but grows unwieldy
class NotifierFactory {
static Notifier create(String type) {
return switch (type) {
case "email" -> new EmailNotifier();
case "sms" -> new SmsNotifier();
default -> throw new IllegalArgumentException("Unknown: " + type);
};
}
}
// ✅ Modern alternative: a registry of suppliers — open for extension,
// no switch to modify when adding a new type
class NotifierRegistry {
private final Map<String, Supplier<Notifier>> registry = new HashMap<>();
void register(String type, Supplier<Notifier> factory) { registry.put(type, factory); }
Notifier create(String type) { return registry.get(type).get(); }
}
// Spring's ApplicationContext.getBean() is effectively this pattern at scale.
Builder — readable construction with many parameters
Problem: constructors with many parameters are error-prone (easy to swap two same-typed arguments) and unreadable at the call site.
public class HttpRequest {
private final String url;
private final String method;
private final int timeoutMs;
private HttpRequest(Builder b) { url = b.url; method = b.method; timeoutMs = b.timeoutMs; }
public static class Builder {
private final String url; // required
private String method = "GET"; // optional, has default
private int timeoutMs = 30_000;
public Builder(String url) { this.url = url; }
public Builder method(String m) { this.method = m; return this; }
public Builder timeout(int ms) { this.timeoutMs = ms; return this; }
public HttpRequest build() { return new HttpRequest(this); }
}
}
HttpRequest req = new HttpRequest.Builder("https://api.example.com")
.method("POST").timeout(5_000).build();
For simple immutable data with all-required fields, a record
(Java 16+) covers the use case in one line —
record HttpRequest(String url, String method, int timeoutMs) {}.
Builder still earns its place when most parameters are optional and you
want named, fluent configuration — exactly the
HttpRequest case above.
Structural Patterns
Adapter — make incompatible interfaces work together
Problem: an existing class's interface doesn't match what your code expects, and you can't (or shouldn't) modify it.
class LegacyPrinter { // third-party, can't modify
void printOldFormat(String text) { System.out.println("[LEGACY] " + text); }
}
interface Printer { void print(String text); } // what your code expects
class LegacyPrinterAdapter implements Printer {
private final LegacyPrinter legacy;
LegacyPrinterAdapter(LegacyPrinter legacy) { this.legacy = legacy; }
@Override public void print(String text) { legacy.printOldFormat(text); }
}
// java.util.Arrays.asList() is a textbook Adapter — wraps an array as a List
String[] arr = {"a", "b"};
List<String> list = Arrays.asList(arr); // array adapted to the List interface
Decorator — add behaviour dynamically
Problem: you want to add features to an object at runtime without subclass explosion (every combination needing its own class).
interface Coffee { String description(); double cost(); }
class SimpleCoffee implements Coffee {
@Override public String description() { return "Coffee"; }
@Override public double cost() { return 2.0; }
}
abstract class CoffeeDecorator implements Coffee {
protected final Coffee base;
CoffeeDecorator(Coffee base) { this.base = base; }
}
class MilkDecorator extends CoffeeDecorator {
MilkDecorator(Coffee base) { super(base); }
@Override public String description() { return base.description() + ", Milk"; }
@Override public double cost() { return base.cost() + 0.5; }
}
// Stack decorators at runtime — any combination, no new classes needed
Coffee order = new MilkDecorator(new SimpleCoffee());
// order.cost() == 2.5
// Java's own I/O is the canonical real-world Decorator:
BufferedReader reader = new BufferedReader( // adds buffering
new InputStreamReader( // converts bytes → chars
new FileInputStream("file.txt"))); // reads raw bytes
Facade — simplify a complex subsystem
Problem: a subsystem has many interacting parts; most callers only need a simple high-level operation.
class ComputerFacade {
private final CPU cpu = new CPU();
private final Memory memory = new Memory();
private final HardDrive disk = new HardDrive();
public void start() { // hides the orchestration of 3 subsystems
cpu.freeze();
memory.load(0, disk.read(0, 1024));
cpu.jump(0);
cpu.execute();
}
}
// Spring's JdbcTemplate is a Facade over raw JDBC's Connection/Statement/ResultSet
// lifecycle management — one method call instead of try/catch/finally boilerplate.
Behavioural Patterns
Observer — notify dependents of state change
Problem: when one object's state changes, an unknown number of other objects need to react — without tight coupling between them.
interface Observer { void update(String event); }
class EventBus {
private final List<Observer> subscribers = new CopyOnWriteArrayList<>();
void subscribe(Observer o) { subscribers.add(o); }
void unsubscribe(Observer o) { subscribers.remove(o); }
void publish(String event) { subscribers.forEach(s -> s.update(event)); }
}
// Modern Java: java.beans.PropertyChangeSupport, or simply functional listeners
List<Consumer<String>> listeners = new ArrayList<>();
listeners.add(event -> System.out.println("Got: " + event));
listeners.forEach(l -> l.accept("new video"));
// At scale, this becomes a message broker (Kafka, RabbitMQ) — Observer is the
// conceptual ancestor of every pub/sub architecture in distributed systems.
Strategy — interchangeable algorithms
Problem: multiple ways to perform an operation, selected at runtime, without conditional branching scattered through the code.
interface PaymentStrategy { void pay(BigDecimal amount); }
class ShoppingCart {
private PaymentStrategy strategy;
void setStrategy(PaymentStrategy s) { strategy = s; }
void checkout(BigDecimal amount) { strategy.pay(amount); }
}
// ✅ Modern Java: lambdas eliminate the boilerplate of concrete strategy classes
cart.setStrategy(amount -> chargeStripe(amount)); // no CreditCardPayment class needed
cart.setStrategy(amount -> chargePayPal(amount));
// Comparator.comparing() IS the Strategy pattern, built into the JDK:
users.sort(Comparator.comparing(User::lastName)); // strategy: sort by name
users.sort(Comparator.comparing(User::age).reversed()); // strategy: sort by age desc
Command — encapsulate a request as an object
Problem: you want to parameterise an action, queue it, log it, or support undo — none of which is possible with a bare method call.
interface Command { void execute(); void undo(); }
class ToggleLightCommand implements Command {
private final Light light;
ToggleLightCommand(Light light) { this.light = light; }
@Override public void execute() { light.turnOn(); }
@Override public void undo() { light.turnOff(); }
}
class RemoteControl {
private final Deque<Command> history = new ArrayDeque<>();
void press(Command cmd) { cmd.execute(); history.push(cmd); }
void undoLast() { if (!history.isEmpty()) history.pop().undo(); }
}
// Spring Batch's Tasklet, and any job queue (Quartz, Spring's @Async with a
// Runnable payload), is fundamentally the Command pattern: an action, packaged
// as an object, that can be scheduled, retried, or logged independently of
// who triggered it.
Pattern Comparison
| Pattern | Solves | Real-world JDK/Spring example |
|---|---|---|
| Singleton | Exactly one shared instance | Spring beans (default scope) |
| Factory | Decouple creation from concrete type | ApplicationContext.getBean() |
| Builder | Readable construction, many optional params | HttpRequest.Builder (JDK 11 HttpClient) |
| Adapter | Bridge incompatible interfaces | Arrays.asList() |
| Decorator | Add behaviour without subclassing | BufferedReader(InputStreamReader(...)) |
| Facade | Simplify a complex subsystem | JdbcTemplate, RestTemplate |
| Observer | Notify multiple listeners of a change | ApplicationEventPublisher, Kafka pub/sub |
| Strategy | Swap algorithms at runtime | Comparator, dependency-injected services |
| Command | Encapsulate an action as an object | Runnable, Spring Batch Tasklet |
Senior Topics: Which GoF Patterns Modern Java Replaces
The 1994 GoF patterns were written for Java/C++ without lambdas, without functional interfaces, without sealed types. Several patterns exist specifically to work around the absence of first-class functions — and are now partially or fully obsolete.
| Pattern | Status in 2026 | Why |
|---|---|---|
| Strategy | Mostly replaced by lambdas | A concrete PaymentStrategy class is now a one-line
lambda. The pattern's intent survives; the boilerplate of
named implementation classes mostly doesn't. |
| Command | Partially replaced | Runnable/Callable + method references
cover the simple case. Full Command (with undo, queuing, logging)
still needs a real object — lambdas can't carry that extra state
cleanly. |
| Observer | Replaced for in-process use; alive at scale | In a single JVM, a List<Consumer<T>> beats
hand-rolled Observer interfaces. Across services, the pattern lives
on as message brokers and event-driven architecture. |
| Singleton | Mostly replaced by DI containers | Manually writing thread-safe Singleton is rare in Spring/CDI applications — the container manages instance scope declaratively. Still relevant in non-DI contexts (utility libraries, static registries). |
| Visitor | Replaced by pattern matching (Java 21) | Sealed interfaces + switch pattern matching achieve
the same double-dispatch goal with compiler-checked exhaustiveness
— no separate Visitor hierarchy needed. |
| Builder | Narrowed by records | Still essential for many optional parameters; redundant for
simple immutable data where record is a one-liner. |
| Decorator, Adapter, Facade, Factory | Fully relevant, unchanged | These solve structural problems orthogonal to functional programming — language features don't replace them. |
Visitor pattern, the old way vs sealed types (Java 21)
// ❌ Classic Visitor: separate hierarchy, double dispatch, verbose
interface ShapeVisitor { void visit(Circle c); void visit(Square s); }
interface Shape { void accept(ShapeVisitor v); }
// ... + a visitor implementation class + accept() in every Shape ...
// ✅ Sealed types + pattern matching: same goal, compiler-checked, no boilerplate
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
// no default needed — compiler verifies ALL Shape subtypes are covered
};
Anti-Patterns
// ❌ One class doing everything — impossible to test in isolation
class Application {
void connectDb() {} void validateUser() {} void sendEmail() {}
void processPayment() {} // ... 50 more methods
}
// ✅ Single Responsibility — each class does one thing
class DatabaseManager {} class UserValidator {} class EmailService {}
// ❌ Arrow code — hard to follow, hard to test each branch
if (user != null) {
if (user.isActive()) {
if (!user.isBlocked()) { /* do something */ }
}
}
// ✅ Guard clauses — flat, each precondition is explicit
if (user == null) return;
if (!user.isActive()) return;
if (user.isBlocked()) return;
// do something
// ❌ Same JDBC boilerplate repeated for every entity type
// ✅ Extract the shared shape, inject what varies
public <T> void save(String sql, Consumer<PreparedStatement> binder) throws SQLException {
try (var conn = dataSource.getConnection();
var stmt = conn.prepareStatement(sql)) {
binder.accept(stmt);
stmt.execute();
}
}
Interview Questions
Q: What is the difference between Factory and Builder?
Factory hides which class gets instantiated — the caller asks for
a capability ("give me a Notifier") without knowing the concrete type.
Builder hides how a complex object is assembled — the type is
known, but it has many parameters, some optional, and direct construction
would be unreadable or error-prone.
Q: When would you use Decorator instead of inheritance?
When you need to combine independent features at runtime, and subclassing
every combination would cause class explosion (a Coffee with milk, caramel,
and whipped cream, in any combination, would need 8 subclasses for 3 binary
options). Decorator wraps objects to add behaviour without modifying the
original class or creating a combinatorial hierarchy.
Q: What problem does Observer solve?
When one object's state change must notify an unknown, possibly changing
set of other objects, without the subject knowing concrete details about
its listeners. The subject only depends on an Observer
interface — new listener types can be added without modifying the subject.
Q: Which classic GoF patterns has modern Java made largely obsolete, and why?
Patterns built to compensate for the absence of first-class functions:
Strategy (now usually a lambda instead of a named class hierarchy), Command
for simple cases (Runnable + method references), and Visitor
(sealed interfaces + exhaustive pattern matching in switch,
Java 21, achieve the same double-dispatch goal with compiler-verified
completeness instead of a parallel visitor hierarchy). Patterns solving
purely structural problems — Adapter, Decorator, Facade — are unaffected by
language evolution because they're orthogonal to functional programming.
Q: How does the Factory pattern relate to dependency injection?
A DI container (Spring's ApplicationContext, CDI) is
conceptually a generalised Factory: you ask it for a type, and it decides
which concrete implementation to provide, based on configuration —
@Qualifier, profiles, conditional beans. The difference is
inversion of control: with a manual Factory, your code calls the factory.
With DI, the container calls you, injecting dependencies you never
explicitly requested. DI is Factory pattern combined with Inversion of
Control as an architectural principle.
Q: Why is Singleton considered an anti-pattern by some, and when is it still legitimate?
Manual Singleton introduces global mutable state, makes unit testing harder
(you can't easily substitute a test double for a hardcoded
getInstance() call), and creates a hidden dependency invisible
in constructor signatures. In DI-managed applications, "singleton scope" is
a configuration concern handled by the container — you write a normal class,
the container decides to create only one instance. Manual Singleton remains
legitimate for: standalone libraries with no DI framework, JVM-wide
immutable constants/registries, or genuinely process-wide resources
(a logging framework's root logger).