Design Patterns in Java
Learn the most important Gang of Four design patterns with production-grade Java implementations — Singleton, Factory, Builder, Observer, and Strategy.
Design patterns are named solutions to recurring design problems. They are not code you copy — they are blueprints that describe how to organise classes and objects to solve a specific structural or behavioural challenge. Knowing these patterns lets you communicate design decisions clearly (“this uses a Strategy pattern”) and recognise them when reading unfamiliar code.
Creational Patterns
Creational patterns deal with object creation. They encapsulate the “how” of constructing objects, making your code independent of the exact classes it instantiates and giving you control over how many instances exist.
Singleton — One Instance, Globally Accessible
Some resources should exist exactly once — a configuration object, a connection pool, or an application-wide event bus. The Singleton pattern guarantees that only one instance is ever created and provides a global access point to it. The initialization-on-demand holder idiom shown here is thread-safe without synchronization overhead on every access.
public final class AppConfig {
// Thread-safe lazy initialisation — the JVM guarantees class loading is atomic
private static final class Holder {
static final AppConfig INSTANCE = new AppConfig();
}
private final Map<String, String> properties;
private AppConfig() {
// load from environment / config file
properties = new HashMap<>();
properties.put("db.host", System.getenv().getOrDefault("DB_HOST", "localhost"));
properties.put("db.port", System.getenv().getOrDefault("DB_PORT", "5432"));
properties.put("app.env", System.getenv().getOrDefault("APP_ENV", "development"));
}
public static AppConfig getInstance() { return Holder.INSTANCE; }
public String get(String key) { return properties.get(key); }
public String get(String key, String defaultValue) { return properties.getOrDefault(key, defaultValue); }
public boolean isProduction() { return "production".equals(get("app.env")); }
}
// Usage
String host = AppConfig.getInstance().get("db.host");
Factory Method — Decouple Object Creation from Usage
When the exact type to instantiate is determined at runtime (based on configuration, user input, or a feature flag), hardcoding new EmailNotification() couples your code to that specific class. The Factory pattern moves creation logic into one place, so callers just ask for “an email notification” without knowing which concrete class implements it.
public interface Notification {
void send(String recipient, String message);
String getChannel();
}
public class EmailNotification implements Notification {
@Override public void send(String to, String msg) {
System.out.printf("Email → %s: %s%n", to, msg);
}
@Override public String getChannel() { return "email"; }
}
public class SmsNotification implements Notification {
@Override public void send(String to, String msg) {
System.out.printf("SMS → %s: %s%n", to, msg);
}
@Override public String getChannel() { return "sms"; }
}
public class PushNotification implements Notification {
@Override public void send(String to, String msg) {
System.out.printf("Push → %s: %s%n", to, msg);
}
@Override public String getChannel() { return "push"; }
}
// All creation logic lives here — adding a new channel means adding one case, not changing callers
public class NotificationFactory {
public static Notification create(String channel) {
return switch (channel.toLowerCase()) {
case "email" -> new EmailNotification();
case "sms" -> new SmsNotification();
case "push" -> new PushNotification();
default -> throw new IllegalArgumentException("Unknown channel: " + channel);
};
}
}
// Caller never references a concrete class
Notification n = NotificationFactory.create("email");
n.send("[email protected]", "Your order has shipped.");
Builder — Fluent Construction of Complex Objects
A constructor with many parameters is hard to read and easy to misuse — it’s not obvious which String is the URL and which is the method, or which boolean flag does what. The Builder pattern lets you construct objects step by step with named setter methods, making call sites self-documenting and allowing any combination of optional fields without an explosion of constructor overloads.
public final class HttpRequest {
private final String method;
private final String url;
private final Map<String, String> headers;
private final String body;
private final int timeoutMs;
private final boolean followRedirects;
private HttpRequest(Builder builder) {
this.method = builder.method;
this.url = builder.url;
this.headers = Map.copyOf(builder.headers);
this.body = builder.body;
this.timeoutMs = builder.timeoutMs;
this.followRedirects = builder.followRedirects;
}
public static Builder builder(String method, String url) {
return new Builder(method, url);
}
@Override
public String toString() {
return String.format("%s %s (timeout=%dms, headers=%s)", method, url, timeoutMs, headers);
}
public static final class Builder {
private final String method;
private final String url;
private final Map<String, String> headers = new LinkedHashMap<>();
private String body = null;
private int timeoutMs = 5_000; // sensible default
private boolean followRedirects = true; // sensible default
private Builder(String method, String url) {
this.method = Objects.requireNonNull(method);
this.url = Objects.requireNonNull(url);
}
// Each setter returns 'this' for chaining
public Builder header(String name, String value) { headers.put(name, value); return this; }
public Builder body(String body) { this.body = body; return this; }
public Builder timeout(int ms) { this.timeoutMs = ms; return this; }
public Builder noRedirects() { this.followRedirects = false; return this; }
public HttpRequest build() { return new HttpRequest(this); }
}
}
// The call site reads like a description of the request
HttpRequest req = HttpRequest.builder("POST", "https://api.example.com/orders")
.header("Authorization", "Bearer token123")
.header("Content-Type", "application/json")
.body("{\"item\":\"Widget\",\"qty\":2}")
.timeout(10_000)
.build();
System.out.println(req);
Behavioural Patterns
Behavioural patterns deal with how objects communicate and distribute responsibility. They make it easy to vary behavior at runtime, decouple senders from receivers, and add new behaviors without modifying existing classes.
Strategy — Swap Algorithms at Runtime
When the same operation can be performed in multiple ways — sorting, compression, payment processing — hardcoding one algorithm forces you to edit the class every time requirements change. The Strategy pattern extracts each algorithm behind an interface so you can swap them at runtime without touching the calling code.
public interface CompressionStrategy {
byte[] compress(byte[] data);
byte[] decompress(byte[] data);
String name();
}
// Each strategy is self-contained — adding a new one is a new class, not a code change
public class GzipCompression implements CompressionStrategy {
@Override public byte[] compress(byte[] data) { System.out.println("GZIP compress"); return data; }
@Override public byte[] decompress(byte[] data) { System.out.println("GZIP decompress"); return data; }
@Override public String name() { return "gzip"; }
}
public class LZ4Compression implements CompressionStrategy {
@Override public byte[] compress(byte[] data) { System.out.println("LZ4 compress (faster)"); return data; }
@Override public byte[] decompress(byte[] data) { System.out.println("LZ4 decompress"); return data; }
@Override public String name() { return "lz4"; }
}
// FileArchiver delegates to the strategy — it never changes when strategies change
public class FileArchiver {
private CompressionStrategy strategy;
public FileArchiver(CompressionStrategy strategy) { this.strategy = strategy; }
public void setStrategy(CompressionStrategy strategy) { this.strategy = strategy; }
public byte[] archive(byte[] data) {
System.out.println("Archiving with " + strategy.name());
return strategy.compress(data);
}
}
FileArchiver archiver = new FileArchiver(new GzipCompression());
archiver.archive(new byte[1024]);
// Switch algorithm at runtime — FileArchiver code is unchanged
archiver.setStrategy(new LZ4Compression());
archiver.archive(new byte[1024]);
Observer — Event-Driven Notification
When one object changes state and multiple others need to react — a new order triggers an email, an inventory update, and an analytics event — you could call each consumer directly. But that makes the producer know about all its consumers, creating tight coupling. The Observer pattern introduces an event bus: producers publish events; consumers subscribe independently. Neither side knows about the other.
public interface EventListener<T> {
void onEvent(T event);
}
public class EventBus<T> {
private final Map<String, List<EventListener<T>>> listeners = new HashMap<>();
public void subscribe(String event, EventListener<T> listener) {
listeners.computeIfAbsent(event, k -> new ArrayList<>()).add(listener);
}
public void unsubscribe(String event, EventListener<T> listener) {
listeners.getOrDefault(event, List.of()).remove(listener);
}
public void publish(String event, T payload) {
// Each subscriber is notified independently — one failure doesn't block others
listeners.getOrDefault(event, List.of())
.forEach(l -> l.onEvent(payload));
}
}
// Domain event — carries all the data subscribers might need
public record OrderPlacedEvent(String orderId, double total, String customerId) {}
// Usage — each subscriber registered independently, producer knows nothing about them
EventBus<OrderPlacedEvent> bus = new EventBus<>();
bus.subscribe("order.placed", e ->
System.out.println("Email sent to " + e.customerId() + " for order " + e.orderId()));
bus.subscribe("order.placed", e ->
System.out.println("Inventory reserved for order " + e.orderId()));
bus.subscribe("order.placed", e ->
System.out.println("Analytics: order total $" + e.total()));
// Publishing notifies all subscribers — adding a new subscriber requires no producer change
bus.publish("order.placed", new OrderPlacedEvent("ORD-001", 59.99, "[email protected]"));
Decorator — Add Behaviour Without Modifying Classes
Sometimes you want to add capabilities to an object — logging, encryption, caching — without subclassing and without modifying the original class. The Decorator pattern wraps an object in another that implements the same interface and adds behavior before or after delegating to the original. Decorators compose: you can stack logging on top of encryption on top of the base implementation.
public interface DataStore {
void save(String key, String value);
String load(String key);
}
// Base implementation — does the real work
public class InMemoryStore implements DataStore {
private final Map<String, String> data = new HashMap<>();
@Override public void save(String key, String value) { data.put(key, value); }
@Override public String load(String key) { return data.get(key); }
}
// Logging decorator — adds log statements, delegates everything else
public class LoggingStore implements DataStore {
private final DataStore wrapped;
public LoggingStore(DataStore wrapped) { this.wrapped = wrapped; }
@Override public void save(String key, String value) {
System.out.printf("[LOG] Saving key=%s%n", key);
wrapped.save(key, value); // delegate to the wrapped store
}
@Override public String load(String key) {
String value = wrapped.load(key);
System.out.printf("[LOG] Loaded key=%s value=%s%n", key, value);
return value;
}
}
// Encryption decorator — encrypts on save, decrypts on load
public class EncryptingStore implements DataStore {
private final DataStore wrapped;
public EncryptingStore(DataStore wrapped) { this.wrapped = wrapped; }
@Override public void save(String key, String value) {
wrapped.save(key, "ENC:" + value); // simplified encryption
}
@Override public String load(String key) {
String raw = wrapped.load(key);
return raw != null && raw.startsWith("ENC:") ? raw.substring(4) : raw;
}
}
// Compose decorators — outermost decorator's behavior runs first
// Order: LoggingStore → EncryptingStore → InMemoryStore
DataStore store = new LoggingStore(new EncryptingStore(new InMemoryStore()));
store.save("token", "abc123"); // [LOG] Saving key=token (then encrypts, then stores)
store.load("token"); // [LOG] Loaded key=token ... (decrypts transparently)
Pattern Selection Guide
| Problem | Pattern |
|---|---|
| Need exactly one instance | Singleton |
| Decouple creation from usage | Factory Method |
| Complex object with many optional fields | Builder |
| Swap algorithm at runtime | Strategy |
| Notify many objects when state changes | Observer |
| Add behaviour without changing the class | Decorator |
| Multiple related object families | Abstract Factory |
| Step-by-step algorithm with variable steps | Template Method |