Design Patterns
Design patterns are battle-tested, reusable blueprints for solving problems that come up again and again in software design. They are not copy-paste code — they are ideas you adapt to your situation, giving you (and your team) a shared vocabulary for talking about solutions.
Why Design Patterns Matter
When a senior engineer says “use a Factory here” or “that smells like it needs an Observer,” they are referencing patterns documented by the Gang of Four (GoF) in their landmark 1994 book. Learning patterns helps you:
- Write code that is easier to extend without breaking existing behavior (Open/Closed Principle).
- Avoid reinventing the wheel for classic structural problems.
- Communicate intent clearly in code reviews and design discussions.
Patterns are grouped into three families:
| Family | Intent | Examples |
|---|---|---|
| Creational | Control how objects are created | Singleton, Factory Method, Builder |
| Structural | Compose classes/objects into larger structures | Adapter, Decorator, Facade |
| Behavioral | Define how objects communicate and delegate | Strategy, Observer, Command |
Creational Patterns
Singleton
Ensures only one instance of a class exists and provides a global access point to it. Classic use cases: configuration holders, thread pools, loggers.
public class AppConfig {
private static volatile AppConfig instance;
private String environment;
private AppConfig() {
this.environment = System.getProperty("env", "production");
}
public static AppConfig getInstance() {
if (instance == null) {
synchronized (AppConfig.class) {
if (instance == null) { // double-checked locking
instance = new AppConfig();
}
}
}
return instance;
}
public String getEnvironment() { return environment; }
}
System.out.println(AppConfig.getInstance().getEnvironment()); // production
Note: The
volatilekeyword prevents the JVM from returning a partially-constructed object due to instruction reordering. See the volatile Keyword page for details.
Tip: An enum-based Singleton (
enum Singleton { INSTANCE; }) is the simplest, thread-safe form and handles serialization correctly for free.
Factory Method
Defines an interface for creating an object but lets subclasses (or a factory class) decide which concrete class to instantiate. This decouples the caller from the implementation.
// Product interface
interface Notification {
void send(String message);
}
// Concrete products
class EmailNotification implements Notification {
public void send(String message) {
System.out.println("Email: " + message);
}
}
class SmsNotification implements Notification {
public void send(String message) {
System.out.println("SMS: " + message);
}
}
// Factory
class NotificationFactory {
public static Notification create(String type) {
return switch (type) {
case "email" -> new EmailNotification();
case "sms" -> new SmsNotification();
default -> throw new IllegalArgumentException("Unknown type: " + type);
};
}
}
Notification n = NotificationFactory.create("sms");
n.send("Your OTP is 4821");
Output:
SMS: Your OTP is 4821
The switch expressions syntax used above (Java 14+) makes the factory clean and exhaustive.
Builder
Useful when constructing a complex object step-by-step, especially when it has many optional fields. Avoids telescoping constructors.
public class HttpRequest {
private final String url;
private final String method;
private final String body;
private final int timeoutMs;
private HttpRequest(Builder b) {
this.url = b.url;
this.method = b.method;
this.body = b.body;
this.timeoutMs = b.timeoutMs;
}
public static class Builder {
private final String url;
private String method = "GET";
private String body = "";
private int timeoutMs = 5000;
public Builder(String url) { this.url = url; }
public Builder method(String m) { this.method = m; return this; }
public Builder body(String b) { this.body = b; return this; }
public Builder timeout(int ms) { this.timeoutMs = ms; return this; }
public HttpRequest build() { return new HttpRequest(this); }
}
@Override public String toString() {
return method + " " + url + " (timeout=" + timeoutMs + "ms)";
}
}
HttpRequest req = new HttpRequest.Builder("https://api.example.com/data")
.method("POST")
.body("{\"key\":\"value\"}")
.timeout(3000)
.build();
System.out.println(req);
Output:
POST https://api.example.com/data (timeout=3000ms)
Tip: Java 16+ Records handle simple immutable data carriers very well and may replace Builder for straightforward cases.
Structural Patterns
Adapter
Converts the interface of a class into another interface that a client expects. Like a power socket adapter for travel.
// Legacy class with an incompatible interface
class LegacyPrinter {
public void printLine(String line) {
System.out.println("[LEGACY] " + line);
}
}
// Target interface expected by the rest of the system
interface ModernPrinter {
void print(String text);
}
// Adapter wraps the legacy class
class PrinterAdapter implements ModernPrinter {
private final LegacyPrinter legacy;
public PrinterAdapter(LegacyPrinter legacy) { this.legacy = legacy; }
@Override
public void print(String text) {
legacy.printLine(text); // delegate
}
}
ModernPrinter printer = new PrinterAdapter(new LegacyPrinter());
printer.print("Hello, Adapter!");
Output:
[LEGACY] Hello, Adapter!
Decorator
Attaches additional responsibilities to an object dynamically, without modifying its class. Java’s own I/O streams are the textbook example — BufferedInputStream wraps FileInputStream to add buffering. See Java I/O for real-world usage.
interface TextProcessor {
String process(String input);
}
class PlainText implements TextProcessor {
public String process(String input) { return input; }
}
class UpperCaseDecorator implements TextProcessor {
private final TextProcessor inner;
public UpperCaseDecorator(TextProcessor inner) { this.inner = inner; }
public String process(String input) { return inner.process(input).toUpperCase(); }
}
class TrimDecorator implements TextProcessor {
private final TextProcessor inner;
public TrimDecorator(TextProcessor inner) { this.inner = inner; }
public String process(String input) { return inner.process(input).trim(); }
}
TextProcessor processor =
new UpperCaseDecorator(new TrimDecorator(new PlainText()));
System.out.println(processor.process(" hello world "));
Output:
HELLO WORLD
Facade
Provides a simplified, high-level interface to a complex subsystem — hiding the messy details behind a friendly front door.
class VideoDecoder { void decode(String file) { System.out.println("Decoding " + file); } }
class AudioMixer { void mix(String file) { System.out.println("Mixing audio for " + file); } }
class VideoEncoder { void encode(String file) { System.out.println("Encoding " + file); } }
// Facade
class VideoConverter {
private final VideoDecoder decoder = new VideoDecoder();
private final AudioMixer mixer = new AudioMixer();
private final VideoEncoder encoder = new VideoEncoder();
public void convert(String file) {
decoder.decode(file);
mixer.mix(file);
encoder.encode(file);
System.out.println("Done: " + file);
}
}
new VideoConverter().convert("movie.avi");
Output:
Decoding movie.avi
Mixing audio for movie.avi
Encoding movie.avi
Done: movie.avi
Behavioral Patterns
Strategy
Defines a family of algorithms, encapsulates each one, and makes them interchangeable. Lets the algorithm vary independently from the clients that use it.
@FunctionalInterface
interface SortStrategy {
void sort(int[] data);
}
class BubbleSort implements SortStrategy {
public void sort(int[] data) { /* bubble sort logic */ System.out.println("Bubble sort"); }
}
class QuickSort implements SortStrategy {
public void sort(int[] data) { /* quick sort logic */ System.out.println("Quick sort"); }
}
class Sorter {
private SortStrategy strategy;
public Sorter(SortStrategy strategy) { this.strategy = strategy; }
public void setStrategy(SortStrategy strategy) { this.strategy = strategy; }
public void sort(int[] data) { strategy.sort(data); }
}
Sorter sorter = new Sorter(new QuickSort());
sorter.sort(new int[]{5, 3, 1, 4}); // Quick sort
sorter.setStrategy(data -> System.out.println("Lambda sort!"));
sorter.sort(new int[]{5, 3, 1, 4}); // Lambda sort!
Tip: Because
SortStrategyis a functional interface, you can pass a lambda expression directly — no extra class needed.
Observer
Defines a one-to-many dependency: when one object (the Subject) changes state, all its registered Observers are notified automatically. The backbone of event-driven systems.
import java.util.ArrayList;
import java.util.List;
interface Observer {
void update(String event);
}
class EventBus {
private final List<Observer> observers = new ArrayList<>();
public void subscribe(Observer o) { observers.add(o); }
public void unsubscribe(Observer o) { observers.remove(o); }
public void publish(String event) {
for (Observer o : observers) {
o.update(event);
}
}
}
EventBus bus = new EventBus();
bus.subscribe(event -> System.out.println("Logger received: " + event));
bus.subscribe(event -> System.out.println("Audit received: " + event));
bus.publish("USER_LOGGED_IN");
Output:
Logger received: USER_LOGGED_IN
Audit received: USER_LOGGED_IN
Command
Encapsulates a request as an object, letting you parameterize clients with different requests, queue or log operations, and support undo.
interface Command {
void execute();
void undo();
}
class Light {
void turnOn() { System.out.println("Light ON"); }
void turnOff() { System.out.println("Light OFF"); }
}
class LightOnCommand implements Command {
private final Light light;
LightOnCommand(Light light) { this.light = light; }
public void execute() { light.turnOn(); }
public void undo() { light.turnOff(); }
}
Light light = new Light();
Command cmd = new LightOnCommand(light);
cmd.execute(); // Light ON
cmd.undo(); // Light OFF
Under the Hood
At the JVM level, patterns impose different costs:
- Singleton with
volatile: Thevolatilekeyword inserts a memory barrier (alockinstruction on x86) ensuring the reference write is fully visible to all threads before any thread can read it. See Java Memory Model. - Strategy with lambdas: The JVM uses
invokedynamicto wire a lambda to a functional interface at runtime. After JIT warm-up, the dispatch can be as cheap as a direct method call — no heap allocation per call in many cases. See JIT Compilation. - Decorator chains: Each wrapping object adds one heap allocation and one virtual dispatch per method call (
invokevirtual). For hot paths, prefer flattening decorators or using a single configurable object. See vtable & Dynamic Dispatch. - Observer lists: Using
ArrayList<Observer>is fine for small subscriber counts. For high-throughput concurrent event buses, preferCopyOnWriteArrayListfrom Concurrent Collections to avoidConcurrentModificationException.
Quick-Reference Cheat Sheet
| Pattern | Category | When to Use |
|---|---|---|
| Singleton | Creational | One shared instance (config, connection pool) |
| Factory Method | Creational | Decouple creation from usage |
| Builder | Creational | Complex object with many optional params |
| Adapter | Structural | Integrate incompatible interfaces |
| Decorator | Structural | Add behavior without subclassing |
| Facade | Structural | Simplify a complex subsystem |
| Strategy | Behavioral | Swap algorithms at runtime |
| Observer | Behavioral | Event/notification systems |
| Command | Behavioral | Encapsulate requests, support undo |
Related Topics
- Interfaces — most patterns rely heavily on programming to interfaces, not implementations
- Lambda Expressions — make Strategy and Command patterns concise with single-method functional interfaces
- Polymorphism — the engine behind behavioral patterns like Strategy and Observer
- Collections Framework — many structural patterns appear inside JDK collection classes
- Java Best Practices — complement design patterns with coding standards for clean, maintainable code
- Clean Code in Java — patterns shine brightest when combined with readable, well-named code