Design Patterns

The GoF patterns that still matter — and which ones modern Java has made obsolete

← Back to Index

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();
Modern alternative: records narrow the need for Builder

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

God Object
// ❌ 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 {}
Deeply nested conditionals
// ❌ 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
Copy-paste duplication
// ❌ 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

🎓 Junior level

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.

🔥 Senior level

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).