OOP Design Patterns
Design patterns are reusable solutions to recurring design problems. They are not copy-paste code — they are templates that describe the shape of a solution. The 23 canonical patterns from the Gang of Four book fall into three categories based on their intent.
| Category | Intent | Patterns covered here |
|---|---|---|
| Creational | Control how objects are created | Singleton, Factory Method, Builder |
| Structural | Compose classes and objects into larger structures | Adapter, Facade, Decorator |
| Behavioral | Define communication and responsibility between objects | Strategy, Observer, Template Method, Command |
Creational Patterns
Creational patterns decouple client code from the mechanics of object construction. They decide which class to instantiate, how many instances exist, and how complex the construction process is — all without the caller knowing the details.
Singleton
Intent: Ensure a class has exactly one instance and provide a global point of access to it.
When to use: Shared resources that are expensive to create and must be consistent across the application — database connection pools, configuration registries, logging services.
When to avoid: Singletons are effectively global state. They make code hard to test (you cannot inject a test double), introduce hidden dependencies, and can cause concurrency bugs if mutable. Prefer dependency injection over Singleton wherever feasible.
Enum Singleton — the preferred Java idiom
The enum approach is thread-safe by the JVM class-loader guarantee, serialization-safe, and reflection-safe — none of the pitfalls of the classic implementation.
public enum AppConfig {
INSTANCE;
private final Properties props;
AppConfig() {
props = new Properties();
props.setProperty("env", "production");
props.setProperty("timeout", "5000");
}
public String get(String key) { return props.getProperty(key); }
}
// Access from anywhere — always the same instance
String env = AppConfig.INSTANCE.get("env"); // "production"Double-checked locking — when enum is not suitable
public class ConnectionPool {
// volatile prevents the JVM from reordering writes before the reference is published
private static volatile ConnectionPool instance;
private ConnectionPool() { /* expensive setup */ }
public static ConnectionPool getInstance() {
if (instance == null) { // first check (no lock)
synchronized (ConnectionPool.class) {
if (instance == null) { // second check (under lock)
instance = new ConnectionPool();
}
}
}
return instance;
}
}
ConnectionPool pool = ConnectionPool.getInstance();Factory Method
Intent: Define an interface for creating an object, but let subclasses (or a factory class) decide which concrete class to instantiate. The caller asks for an object without knowing — or caring — which class it gets back.
When to use: When the exact type to create is determined at runtime by configuration or user input; when you want to keep construction logic in one place rather than scattered across the codebase; when you need to swap implementations without touching calling code.
// Product interface — callers depend only on this
public interface Notification {
void send(String recipient, String message);
}
// Concrete products
public class EmailNotification implements Notification {
@Override
public void send(String recipient, String message) {
System.out.println("Email → " + recipient + ": " + message);
}
}
public class SmsNotification implements Notification {
@Override
public void send(String recipient, String message) {
System.out.println("SMS → " + recipient + ": " + message);
}
}
public class PushNotification implements Notification {
@Override
public void send(String recipient, String message) {
System.out.println("Push → " + recipient + ": " + message);
}
}
// Factory — the single place where the concrete type decision lives
public class NotificationFactory {
public static Notification create(String channel) {
return switch (channel.toUpperCase()) {
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 — only the interface
String channel = userPreferences.getNotificationChannel(); // runtime value
Notification n = NotificationFactory.create(channel);
n.send("alice@example.com", "Your order has shipped!");Adding a new channel (say, Slack) means adding a new class and one case in the factory — no other code changes. This is the OCP applied to object creation.
Builder
Intent: Construct a complex object step by step. The same construction process can produce objects with different configurations without exposing a constructor with a dozen parameters.
When to use: Objects with many optional fields; immutable objects with non-trivial construction; anywhere you would otherwise write a "telescoping constructor" (multiple overloaded constructors with growing parameter lists).
The telescoping constructor problem
// Hard to read — which boolean is which? what does null mean?
HttpRequest req = new HttpRequest(
"POST",
"https://api.example.com/orders",
Map.of("Authorization", "Bearer tok"),
"{"qty":2}",
3000,
true,
null
);Builder solution
public 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 HttpRequest(Builder b) {
this.method = b.method;
this.url = b.url;
this.headers = Map.copyOf(b.headers);
this.body = b.body;
this.timeoutMs = b.timeoutMs;
}
public String getMethod() { return method; }
public String getUrl() { return url; }
public Map<String, String> getHeaders() { return headers; }
public String getBody() { return body; }
public int getTimeoutMs() { return timeoutMs; }
public static class Builder {
// Required fields
private final String method;
private final String url;
// Optional fields with sensible defaults
private final Map<String, String> headers = new HashMap<>();
private String body = null;
private int timeoutMs = 5_000;
public Builder(String method, String url) {
this.method = method;
this.url = url;
}
public Builder header(String key, String value) {
headers.put(key, value);
return this;
}
public Builder body(String body) { this.body = body; return this; }
public Builder timeout(int ms) { this.timeoutMs = ms; return this; }
public HttpRequest build() { return new HttpRequest(this); }
}
}
// Readable, fluent, and impossible to mix up argument order
HttpRequest request = new HttpRequest.Builder("POST", "https://api.example.com/orders")
.header("Authorization", "Bearer token123")
.header("Content-Type", "application/json")
.body("{"item": "book", "qty": 2}")
.timeout(3_000)
.build();Structural Patterns
Structural patterns deal with how classes and objects are assembled into larger structures. They use interfaces and composition to let incompatible types work together, wrap objects with new behaviour, or simplify a complex subsystem behind a single surface.
Adapter
Intent: Convert the interface of an existing class into another interface that clients expect. Lets otherwise incompatible classes work together without modifying their source code.
When to use: Integrating third-party libraries or legacy code that you cannot modify; when you want to unify multiple external APIs behind one internal interface.
// ── Third-party SDKs we cannot change ───────────────────────────
public class StripeGateway {
public boolean chargeCard(String cardToken, int amountCents) {
System.out.println("Stripe: charging " + amountCents + "¢ on token " + cardToken);
return true;
}
}
public class PayPalGateway {
public String executePayment(String email, double amountUsd) {
System.out.println("PayPal: $" + amountUsd + " to " + email);
return "PP-TXN-" + System.currentTimeMillis();
}
}
// ── Our application's unified interface ──────────────────────────
public interface PaymentProcessor {
boolean process(String recipient, double amountUsd);
}
// ── Adapters translate our interface to each SDK's interface ─────
public class StripeAdapter implements PaymentProcessor {
private final StripeGateway stripe;
public StripeAdapter(StripeGateway stripe) { this.stripe = stripe; }
@Override
public boolean process(String cardToken, double amountUsd) {
int cents = (int) (amountUsd * 100);
return stripe.chargeCard(cardToken, cents);
}
}
public class PayPalAdapter implements PaymentProcessor {
private final PayPalGateway paypal;
public PayPalAdapter(PayPalGateway paypal) { this.paypal = paypal; }
@Override
public boolean process(String email, double amountUsd) {
String txn = paypal.executePayment(email, amountUsd);
return txn != null;
}
}
// ── Calling code depends only on PaymentProcessor ────────────────
public class CheckoutService {
private final PaymentProcessor processor;
public CheckoutService(PaymentProcessor processor) {
this.processor = processor;
}
public void checkout(String recipient, double amount) {
if (!processor.process(recipient, amount)) {
throw new RuntimeException("Payment failed");
}
System.out.println("Order confirmed");
}
}
// Swap the payment provider without changing CheckoutService
CheckoutService stripeCheckout = new CheckoutService(new StripeAdapter(new StripeGateway()));
CheckoutService paypalCheckout = new CheckoutService(new PayPalAdapter(new PayPalGateway()));
stripeCheckout.checkout("tok_visa_123", 49.99);
paypalCheckout.checkout("alice@example.com", 49.99);Facade
Intent: Provide a simple, unified interface to a complex subsystem. The facade does not add functionality — it hides orchestration complexity so callers can achieve common tasks with a single method call.
When to use: When a subsystem has many classes that must be coordinated in a specific order; when you want to give a library or module a cleaner public API; when client code repeats the same multi-step orchestration in several places.
// ── Complex subsystems ──────────────────────────────────────────
public class InventoryService {
public boolean checkStock(String productId, int qty) {
System.out.println("Inventory: checking stock for " + productId);
return true;
}
public void reserve(String productId, int qty) {
System.out.println("Inventory: reserved " + qty + "x " + productId);
}
}
public class PaymentService {
public boolean charge(String customerId, double amount) {
System.out.println("Payment: charged $" + amount + " to " + customerId);
return true;
}
}
public class ShippingService {
public String createShipment(String orderId, String address) {
String trackingId = "TRK-" + orderId.hashCode();
System.out.println("Shipping: created shipment " + trackingId + " → " + address);
return trackingId;
}
}
public class EmailService {
public void sendConfirmation(String email, String trackingId) {
System.out.println("Email: sent confirmation to " + email + " (tracking: " + trackingId + ")");
}
}
// ── Facade ───────────────────────────────────────────────────────
public class OrderFacade {
private final InventoryService inventory = new InventoryService();
private final PaymentService payment = new PaymentService();
private final ShippingService shipping = new ShippingService();
private final EmailService email = new EmailService();
public String placeOrder(String productId, int qty,
String customerId, double amount,
String customerEmail, String address) {
if (!inventory.checkStock(productId, qty)) {
throw new IllegalStateException("Out of stock: " + productId);
}
inventory.reserve(productId, qty);
if (!payment.charge(customerId, amount)) {
throw new IllegalStateException("Payment declined");
}
String orderId = customerId + "-" + productId;
String trackingId = shipping.createShipment(orderId, address);
email.sendConfirmation(customerEmail, trackingId);
return trackingId;
}
}
// ── Caller sees one method, not six subsystem classes ────────────
OrderFacade orders = new OrderFacade();
String tracking = orders.placeOrder(
"BOOK-42", 1,
"cus_abc", 29.99,
"alice@example.com", "221B Baker Street"
);Decorator
Intent: Attach additional responsibilities to an object dynamically, by wrapping it in decorator objects that share the same interface. Decorators provide a flexible alternative to subclassing for extending behaviour.
When to use: When you need to add orthogonal concerns (logging, caching, validation, compression, encryption) to an object without modifying its class; when the combinations of features would create an explosion of subclasses (e.g. CachedLoggingEncryptedDataSource). Java's own I/O library is the canonical example — BufferedInputStream wraps any InputStream.
// ── Component interface ─────────────────────────────────────────
public interface DataSource {
void write(String data);
String read();
}
// ── Concrete component ───────────────────────────────────────────
public class FileDataSource implements DataSource {
private final String filename;
private String stored = "";
public FileDataSource(String filename) { this.filename = filename; }
@Override public void write(String data) { stored = data; }
@Override public String read() { return stored; }
}
// ── Base decorator — delegates everything to the wrapped component
public abstract class DataSourceDecorator implements DataSource {
protected final DataSource wrapped;
public DataSourceDecorator(DataSource source) { this.wrapped = source; }
@Override public void write(String data) { wrapped.write(data); }
@Override public String read() { return wrapped.read(); }
}
// ── Concrete decorators — each adds exactly one responsibility ────
public class EncryptionDecorator extends DataSourceDecorator {
public EncryptionDecorator(DataSource source) { super(source); }
@Override
public void write(String data) {
super.write(encrypt(data));
}
@Override
public String read() {
return decrypt(super.read());
}
private String encrypt(String d) { return "[ENC:" + d + "]"; }
private String decrypt(String d) { return d.replaceAll("\[ENC:(.*)]", "$1"); }
}
public class CompressionDecorator extends DataSourceDecorator {
public CompressionDecorator(DataSource source) { super(source); }
@Override
public void write(String data) {
super.write(compress(data));
}
private String compress(String d) { return "[ZIP:" + d + "]"; }
}
public class LoggingDecorator extends DataSourceDecorator {
public LoggingDecorator(DataSource source) { super(source); }
@Override
public void write(String data) {
System.out.println("Writing " + data.length() + " chars");
super.write(data);
}
}
// ── Stack decorators like Russian dolls ──────────────────────────
DataSource source =
new LoggingDecorator(
new CompressionDecorator(
new EncryptionDecorator(
new FileDataSource("data.txt"))));
source.write("Hello, World!");
// "Writing 13 chars"
// stored as: [ZIP:[ENC:Hello, World!]]Behavioral Patterns
Behavioral patterns define how objects communicate and distribute responsibility. They capture workflows that would otherwise be scattered across many classes or hardcoded with if/else chains.
Strategy
Intent: Define a family of algorithms, encapsulate each one in its own class, and make them interchangeable at runtime. The context delegates the work to whichever strategy it holds, without knowing the details of the algorithm.
When to use: When you have multiple variants of an algorithm (sorting, pricing, authentication, routing) and want to select or swap them at runtime without touching the client; when if/else or switch chains are growing to accommodate new algorithm variants.
// ── Strategy interface ──────────────────────────────────────────
public interface ShippingStrategy {
double calculate(double weightKg, double distanceKm);
String label();
}
// ── Concrete strategies ──────────────────────────────────────────
public class StandardShipping implements ShippingStrategy {
@Override public double calculate(double kg, double km) { return 2.0 + kg * 0.5; }
@Override public String label() { return "Standard (5-7 days)"; }
}
public class ExpressShipping implements ShippingStrategy {
@Override public double calculate(double kg, double km) { return 10.0 + kg * 1.2 + km * 0.01; }
@Override public String label() { return "Express (1-2 days)"; }
}
public class FreeShipping implements ShippingStrategy {
@Override public double calculate(double kg, double km) { return 0.0; }
@Override public String label() { return "Free Shipping"; }
}
// ── Context — holds and delegates to a strategy ──────────────────
public class ShippingCalculator {
private ShippingStrategy strategy;
public ShippingCalculator(ShippingStrategy strategy) {
this.strategy = strategy;
}
public void setStrategy(ShippingStrategy strategy) {
this.strategy = strategy;
}
public void printQuote(double kg, double km) {
double cost = strategy.calculate(kg, km);
System.out.printf("%s: $%.2f%n", strategy.label(), cost);
}
}
// ── Runtime selection ────────────────────────────────────────────
ShippingCalculator calc = new ShippingCalculator(new StandardShipping());
calc.printQuote(2.5, 300); // Standard (5-7 days): $3.25
calc.setStrategy(new ExpressShipping());
calc.printQuote(2.5, 300); // Express (1-2 days): $16.00
// Loyalty users get free shipping — swap the strategy, same context
if (customer.isLoyaltyMember() && order.total() > 50) {
calc.setStrategy(new FreeShipping());
}
calc.printQuote(2.5, 300); // Free Shipping: $0.00Observer / PubSub
Intent: Define a one-to-many dependency so that when one object (the subject / publisher) changes state, all its dependents (observers / subscribers) are notified and updated automatically — without the subject knowing who is listening.
When to use: Event systems, UI data-binding, domain event propagation, real-time notifications. It is the backbone of every framework that decouples producers from consumers — Spring's ApplicationEvent, RxJava, and the browser's DOM event system all implement this pattern.
// ── Observer interface ──────────────────────────────────────────
public interface OrderEventListener {
void onOrderPlaced(Order order);
}
// ── Subject (Publisher) ──────────────────────────────────────────
public class OrderService {
private final List<OrderEventListener> listeners = new ArrayList<>();
public void subscribe(OrderEventListener listener) {
listeners.add(listener);
}
public void unsubscribe(OrderEventListener listener) {
listeners.remove(listener);
}
public void placeOrder(Order order) {
// Core business logic
System.out.println("Order placed: " + order.getId());
// Notify all subscribers — OrderService doesn't know or care who they are
for (OrderEventListener listener : listeners) {
listener.onOrderPlaced(order);
}
}
}
// ── Concrete observers ───────────────────────────────────────────
public class InventoryListener implements OrderEventListener {
@Override
public void onOrderPlaced(Order order) {
System.out.println("Inventory: reserving stock for order " + order.getId());
}
}
public class EmailListener implements OrderEventListener {
@Override
public void onOrderPlaced(Order order) {
System.out.println("Email: sending confirmation to " + order.getCustomerEmail());
}
}
public class AnalyticsListener implements OrderEventListener {
@Override
public void onOrderPlaced(Order order) {
System.out.println("Analytics: recording order event");
}
}
// ── Wire it up ───────────────────────────────────────────────────
OrderService orderService = new OrderService();
orderService.subscribe(new InventoryListener());
orderService.subscribe(new EmailListener());
orderService.subscribe(new AnalyticsListener());
// New listeners can be added without touching OrderService
orderService.placeOrder(new Order("ORD-001", "alice@example.com"));Java built-in: using functional interfaces
For lightweight use cases you can replace the OrderEventListener interface with a lambda-friendly Consumer, removing the need for a dedicated interface:
import java.util.function.Consumer;
public class EventBus<T> {
private final List<Consumer<T>> subscribers = new ArrayList<>();
public void subscribe(Consumer<T> handler) { subscribers.add(handler); }
public void publish(T event) { subscribers.forEach(h -> h.accept(event)); }
}
EventBus<Order> bus = new EventBus<>();
bus.subscribe(order -> System.out.println("Email sent to " + order.getCustomerEmail()));
bus.subscribe(order -> System.out.println("Stock reserved for order " + order.getId()));
bus.publish(new Order("ORD-002", "bob@example.com"));Template Method
Intent: Define the skeleton of an algorithm in a base class, deferring specific steps to subclasses. The overall flow is fixed; what varies is plugged in by the subclass.
When to use: When multiple classes share the same overall process but differ in specific steps — data parsers, report generators, export pipelines. It is the pattern behind Spring's JdbcTemplate, RestTemplate, and JUnit's test lifecycle.
// ── Abstract class defines the invariant skeleton ───────────────
public abstract class DataExporter {
// Template method — the fixed algorithm
public final void export(String destination) {
List<String> raw = fetchData();
List<String> clean = transform(raw);
String output = format(clean);
write(output, destination);
System.out.println("Export complete → " + destination);
}
protected abstract List<String> fetchData(); // subclass provides data source
protected abstract List<String> transform(List<String> data); // subclass provides logic
// Optional hook — subclass can override or leave as-is
protected String format(List<String> rows) {
return String.join("
", rows);
}
private void write(String content, String dest) {
System.out.println("Writing to " + dest + ":
" + content);
}
}
// ── Concrete exporters — only override what varies ───────────────
public class CsvExporter extends DataExporter {
private final List<String> source;
public CsvExporter(List<String> source) { this.source = source; }
@Override
protected List<String> fetchData() { return source; }
@Override
protected List<String> transform(List<String> data) {
return data.stream()
.map(row -> row.replace("|", ","))
.toList();
}
}
public class HtmlExporter extends DataExporter {
private final List<String> source;
public HtmlExporter(List<String> source) { this.source = source; }
@Override
protected List<String> fetchData() { return source; }
@Override
protected List<String> transform(List<String> data) {
return data.stream()
.map(row -> "<tr><td>" + row + "</td></tr>")
.toList();
}
@Override
protected String format(List<String> rows) {
return "<table>
" + String.join("
", rows) + "
</table>";
}
}
// ── Usage ────────────────────────────────────────────────────────
List<String> data = List.of("Alice|90", "Bob|75", "Carol|85");
new CsvExporter(data).export("report.csv");
new HtmlExporter(data).export("report.html");The final keyword on the template method prevents subclasses from accidentally overriding the algorithm structure — only the designated hook methods can be customized.
Command
Intent: Encapsulate a request as a self-contained object. This lets you parameterize methods with requests, queue them, log them, and support undo/redo.
When to use: Undo/redo stacks in editors; task queues and schedulers; audit logs where every action must be recorded; GUI button actions that need to be decoupled from business logic; transactional operations that must support rollback.
// ── Command interface ───────────────────────────────────────────
public interface Command {
void execute();
void undo();
}
// ── Receiver — the object that knows how to do the actual work ───
public class TextEditor {
private final StringBuilder text = new StringBuilder();
public void insertText(String s, int pos) {
text.insert(pos, s);
System.out.println("Text: "" + text + """);
}
public void deleteText(int pos, int length) {
text.delete(pos, pos + length);
System.out.println("Text: "" + text + """);
}
public String getText() { return text.toString(); }
}
// ── Concrete commands ────────────────────────────────────────────
public class InsertCommand implements Command {
private final TextEditor editor;
private final String text;
private final int position;
public InsertCommand(TextEditor editor, String text, int position) {
this.editor = editor;
this.text = text;
this.position = position;
}
@Override public void execute() { editor.insertText(text, position); }
@Override public void undo() { editor.deleteText(position, text.length()); }
}
// ── Invoker — manages command history and drives undo/redo ───────
public class CommandHistory {
private final Deque<Command> history = new ArrayDeque<>();
public void execute(Command cmd) {
cmd.execute();
history.push(cmd);
}
public void undo() {
if (!history.isEmpty()) {
history.pop().undo();
}
}
}
// ── Usage ────────────────────────────────────────────────────────
TextEditor editor = new TextEditor();
CommandHistory history = new CommandHistory();
history.execute(new InsertCommand(editor, "Hello", 0)); // Text: "Hello"
history.execute(new InsertCommand(editor, ", World", 5)); // Text: "Hello, World"
history.undo(); // Text: "Hello"
history.undo(); // Text: ""Task queue variant
The Command pattern also makes it trivial to build a task queue — commands are objects, so they can be stored, serialized, and executed later.
Queue<Command> taskQueue = new LinkedList<>();
// Producer enqueues work
taskQueue.offer(() -> System.out.println("Send email"));
taskQueue.offer(() -> System.out.println("Generate report"));
taskQueue.offer(() -> System.out.println("Update dashboard"));
// Consumer processes tasks — has no idea what each task does
while (!taskQueue.isEmpty()) {
Command task = taskQueue.poll();
task.execute(); // undo() not used here — lambdas implement execute() only
}