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.

CategoryIntentPatterns covered here
CreationalControl how objects are createdSingleton, Factory Method, Builder
StructuralCompose classes and objects into larger structuresAdapter, Facade, Decorator
BehavioralDefine communication and responsibility between objectsStrategy, 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.00

Observer / 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
}