Skip to main content
Java · lesson 25 of 66
Open the Java page →
Java advanced Lesson 25 of 66

Also part of: Low-Level Design

Design Patterns in Java

Learn the most important Gang of Four design patterns with production-grade Java implementations — Singleton, Factory, Builder, Observer, and Strategy.

By SkillByExample · Updated

Design patterns are named solutions to recurring design problems. They are blueprints for organising classes, not code to copy, and knowing them lets you say “this is a Strategy” and be understood. Each example below is a single file run with the JDK 21 source launcher (java File.java).

Singleton: exactly one instance

Some objects should exist once per application — configuration, a registry, a connection pool. The holder idiom creates the instance lazily, and the JVM’s class-initialisation lock makes that thread-safe without synchronized on every call.

import java.util.Map;

public class SingletonDemo {
    public static void main(String[] args) {
        System.out.println("before first use");
        AppConfig a = AppConfig.getInstance();
        AppConfig b = AppConfig.getInstance();
        System.out.println("same object? " + (a == b));
        System.out.println("db.host = " + a.get("db.host"));
    }
}

final class AppConfig {
    private static final class Holder {
        static final AppConfig INSTANCE = new AppConfig();
    }

    private final Map<String, String> props;

    private AppConfig() {
        System.out.println("loading config (runs once)");
        props = Map.of("db.host", System.getenv().getOrDefault("DB_HOST", "localhost"));
    }

    static AppConfig getInstance() { return Holder.INSTANCE; }
    String get(String key)         { return props.get(key); }
}
$ java SingletonDemo.java
before first use
loading config (runs once)
same object? true
db.host = localhost

“loading config” prints after “before first use”: Holder isn’t initialised until getInstance() first touches it, and never again. The constructor is private, so new AppConfig() outside the class is a compile error. DB_HOST was not set in this shell, so the default applied.

Factory: decouple creation from use

A factory method picks the concrete class from a runtime value, so callers depend only on the interface.

public class FactoryDemo {
    public static void main(String[] args) {
        Notification n = NotificationFactory.create("email");
        n.send("alice@example.com", "Your order has shipped.");
        NotificationFactory.create("SMS").send("+15551234567", "Your order has shipped.");
        NotificationFactory.create("carrier-pigeon");
    }
}

interface Notification { void send(String to, String msg); }

class NotificationFactory {
    static Notification create(String channel) {
        return switch (channel.toLowerCase()) {
            case "email" -> (to, msg) -> System.out.println("Email -> " + to + ": " + msg);
            case "sms"   -> (to, msg) -> System.out.println("SMS -> " + to + ": " + msg);
            default      -> throw new IllegalArgumentException("Unknown channel: " + channel);
        };
    }
}
$ java FactoryDemo.java
Email -> alice@example.com: Your order has shipped.
SMS -> +15551234567: Your order has shipped.
Exception in thread "main" java.lang.IllegalArgumentException: Unknown channel: carrier-pigeon
	at NotificationFactory.create(FactoryDemo.java:17)
	at FactoryDemo.main(FactoryDemo.java:6)

main never names a concrete class; Notification is a functional interface, so each channel here is a lambda (in real code, often a full class). An unknown channel fails fast at creation instead of producing an object that silently does nothing.

Builder: many optional parameters

A constructor like new HttpRequest("POST", url, null, 10000, false, headers) is unreadable. A builder names every argument, supplies defaults, and validates once in build().

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;

public class BuilderDemo {
    public static void main(String[] args) {
        HttpRequest req = HttpRequest.builder("POST", "https://api.example.com/orders")
            .header("Authorization", "Bearer token123")
            .header("Content-Type", "application/json")
            .timeout(10_000)
            .build();
        System.out.println(req);
        System.out.println(HttpRequest.builder("GET", "https://example.com").build());
        HttpRequest.builder("GET", "https://example.com").timeout(-1).build();
    }
}

record HttpRequest(String method, String url, Map<String, String> headers, int timeoutMs) {
    static Builder builder(String method, String url) { return new Builder(method, url); }

    static final class Builder {
        private final String method, url;
        private final Map<String, String> headers = new LinkedHashMap<>();
        private int timeoutMs = 5_000;                       // default
        private Builder(String method, String url) { this.method = method; this.url = url; }

        Builder header(String k, String v) { headers.put(k, v); return this; }
        Builder timeout(int ms)            { timeoutMs = ms; return this; }
        HttpRequest build() {
            if (timeoutMs <= 0) throw new IllegalStateException("timeout must be positive: " + timeoutMs);
            return new HttpRequest(method, url, Collections.unmodifiableMap(new LinkedHashMap<>(headers)), timeoutMs);
        }
    }
}
$ java BuilderDemo.java
HttpRequest[method=POST, url=https://api.example.com/orders, headers={Authorization=Bearer token123, Content-Type=application/json}, timeoutMs=10000]
HttpRequest[method=GET, url=https://example.com, headers={}, timeoutMs=5000]
Exception in thread "main" java.lang.IllegalStateException: timeout must be positive: -1
	at HttpRequest$Builder.build(BuilderDemo.java:30)
	at BuilderDemo.main(BuilderDemo.java:14)

Each setter returns this, which is what makes the chain work. The second request shows the default timeout, and the third shows validation happening once, in build(). The headers are copied into a fresh LinkedHashMap so they keep insertion order and later builder calls can’t change a request that was already built; Map.copyOf would also copy, but its iteration order is unspecified and can differ from run to run.

Strategy: swap an algorithm at runtime

Extract each algorithm behind an interface and hold it in a field; the class using it never changes.

import java.util.*;

public class StrategyDemo {
    public static void main(String[] args) {
        List<String> words = List.of("kiwi", "Banana", "apple", "fig");
        Sorter sorter = new Sorter(Comparator.naturalOrder());
        System.out.println(sorter.sort(words));
        sorter.setStrategy(String.CASE_INSENSITIVE_ORDER);
        System.out.println(sorter.sort(words));
        sorter.setStrategy(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
        System.out.println(sorter.sort(words));
    }
}

class Sorter {
    private Comparator<String> strategy;
    Sorter(Comparator<String> strategy) { this.strategy = strategy; }
    void setStrategy(Comparator<String> s) { this.strategy = s; }
    List<String> sort(List<String> in) {
        List<String> copy = new ArrayList<>(in);
        copy.sort(strategy);
        return copy;
    }
}
$ java StrategyDemo.java
[Banana, apple, fig, kiwi]
[apple, Banana, fig, kiwi]
[fig, kiwi, apple, Banana]

Comparator is the Strategy pattern in the JDK. Natural order puts uppercase before lowercase, which is why “Banana” comes first in line one. Sorter.sort is identical for all three runs; only the strategy object changed.

Observer: publish once, notify many

Producers publish events; subscribers register independently. Neither side knows the other’s classes.

import java.util.*;
import java.util.function.Consumer;

public class ObserverDemo {
    record OrderPlaced(String orderId, double total, String customer) {}

    public static void main(String[] args) {
        EventBus<OrderPlaced> bus = new EventBus<>();
        bus.subscribe(e -> System.out.println("Email sent to " + e.customer()));
        bus.subscribe(e -> System.out.println("Inventory reserved for " + e.orderId()));
        bus.subscribe(e -> System.out.println("Analytics: total $" + e.total()));
        bus.publish(new OrderPlaced("ORD-001", 59.99, "alice@example.com"));
    }
}

class EventBus<T> {
    private final List<Consumer<T>> listeners = new ArrayList<>();
    void subscribe(Consumer<T> l) { listeners.add(l); }
    void publish(T event)         { listeners.forEach(l -> l.accept(event)); }
}
$ java ObserverDemo.java
Email sent to alice@example.com
Inventory reserved for ORD-001
Analytics: total $59.99

One publish call reaches all three subscribers, in subscription order. Adding a fourth needs no change to EventBus or to the code that publishes.

Decorator: layer behaviour, and order matters

A decorator implements the same interface as the object it wraps. Stacking them combines behaviours — and the stacking order changes what each layer sees.

import java.util.*;

public class StoreDemo {
    public static void main(String[] args) {
        DataStore a = new LoggingStore(new EncryptingStore(new MemoryStore()));
        a.save("token", "abc123");
        DataStore b = new EncryptingStore(new LoggingStore(new MemoryStore()));
        b.save("token", "abc123");
    }
}

interface DataStore { void save(String key, String value); }

class MemoryStore implements DataStore {
    private final Map<String, String> data = new HashMap<>();
    public void save(String k, String v) { data.put(k, v); }
}
class LoggingStore implements DataStore {
    private final DataStore inner;
    LoggingStore(DataStore inner) { this.inner = inner; }
    public void save(String k, String v) { System.out.println("[LOG] save " + k + "=" + v); inner.save(k, v); }
}
class EncryptingStore implements DataStore {
    private final DataStore inner;
    EncryptingStore(DataStore inner) { this.inner = inner; }
    public void save(String k, String v) { inner.save(k, "ENC(" + new StringBuilder(v).reverse() + ")"); }
}
$ java StoreDemo.java
[LOG] save token=abc123
[LOG] save token=ENC(321cba)

In a the logger is outermost, so it logs the plaintext before encryption — a secret leaked to the logs. In b the logger sits inside the encryptor and only ever sees ciphertext. (The “encryption” is a reversed string; never use it for real data.)

Pattern selection guide

ProblemPattern
Exactly one instanceSingleton
Choose a concrete class at runtimeFactory
Many optional constructor argumentsBuilder
Swap an algorithm at runtimeStrategy
Notify many objects of an eventObserver
Add behaviour without changing a classDecorator
Fixed algorithm with variable stepsTemplate Method

Practice

  1. Rewrite AppConfig as an enum singleton and show that two lookups return the same instance.
Solution
public class EnumSingleton {
    public static void main(String[] args) {
        System.out.println(Config.INSTANCE == Config.valueOf("INSTANCE"));
        System.out.println(Config.INSTANCE.get("db.host"));
    }
}

enum Config {
    INSTANCE;
    private final java.util.Map<String, String> props = java.util.Map.of("db.host", "localhost");
    String get(String key) { return props.get(key); }
}
$ java EnumSingleton.java
true
localhost

The JVM creates each enum constant exactly once, so the enum is a singleton with no holder class and no private constructor to write.

  1. Make EventBus.publish keep going when one listener throws: catch, report, continue.
Solution
import java.util.*;
import java.util.function.Consumer;

public class SafeBus {
    public static void main(String[] args) {
        EventBus<String> bus = new EventBus<>();
        bus.subscribe(e -> System.out.println("first got " + e));
        bus.subscribe(e -> { throw new IllegalStateException("second is broken"); });
        bus.subscribe(e -> System.out.println("third got " + e));
        bus.publish("ORD-002");
    }
}

class EventBus<T> {
    private final List<Consumer<T>> listeners = new ArrayList<>();
    void subscribe(Consumer<T> l) { listeners.add(l); }
    void publish(T event) {
        for (Consumer<T> l : listeners) {
            try { l.accept(event); }
            catch (RuntimeException ex) { System.out.println("listener failed: " + ex.getMessage()); }
        }
    }
}
$ java SafeBus.java
first got ORD-002
listener failed: second is broken
third got ORD-002

Without the try, the exception would escape forEach and the third listener would never run.

  1. Add a "push" channel to NotificationFactory and send a message through it.
Solution
public class PushFactory {
    public static void main(String[] args) {
        NotificationFactory.create("push").send("device-42", "Sale starts now");
    }
}

interface Notification { void send(String to, String msg); }

class NotificationFactory {
    static Notification create(String channel) {
        return switch (channel.toLowerCase()) {
            case "email" -> (to, msg) -> System.out.println("Email -> " + to + ": " + msg);
            case "sms"   -> (to, msg) -> System.out.println("SMS -> " + to + ": " + msg);
            case "push"  -> (to, msg) -> System.out.println("Push -> " + to + ": " + msg);
            default      -> throw new IllegalArgumentException("Unknown channel: " + channel);
        };
    }
}
$ java PushFactory.java
Push -> device-42: Sale starts now

Only the factory changed; every caller that asks for a channel by name can now get push.

  1. Add a CountingStore decorator that counts save calls, wrap it around an encrypting chain (EncryptingStore over MemoryStore) as in StoreDemo, and print the count after three saves.
Solution
import java.util.*;

public class CountingDemo {
    public static void main(String[] args) {
        CountingStore store = new CountingStore(new EncryptingStore(new MemoryStore()));
        store.save("a", "1"); store.save("b", "2"); store.save("a", "3");
        System.out.println("saves: " + store.count);
    }
}

interface DataStore { void save(String key, String value); }
class MemoryStore implements DataStore {
    private final Map<String, String> data = new HashMap<>();
    public void save(String k, String v) { data.put(k, v); }
}
class EncryptingStore implements DataStore {
    private final DataStore inner;
    EncryptingStore(DataStore inner) { this.inner = inner; }
    public void save(String k, String v) { inner.save(k, "ENC(" + new StringBuilder(v).reverse() + ")"); }
}
class CountingStore implements DataStore {
    private final DataStore inner;
    int count = 0;
    CountingStore(DataStore inner) { this.inner = inner; }
    public void save(String k, String v) { count++; inner.save(k, v); }
}
$ java CountingDemo.java
saves: 3

For counting, position in the chain doesn’t matter — every layer sees every call. It matters only for decorators that look at or change the data, like logging versus encryption.

Frequently Asked Questions

Do I need to memorise all 23 GoF design patterns?
No. Focus on the ones you encounter regularly: Singleton, Factory, Builder, Strategy, Observer, and Decorator. The others you pick up as needed. Understanding the problem each pattern solves matters more than memorising the pattern itself.
Are design patterns always a good idea?
No. Patterns add structure, which adds complexity. Only apply a pattern when the problem it solves is real in your codebase. Applying patterns preemptively creates over-engineered, harder-to-read code.
What is the difference between a Factory and a Builder?
A Factory creates objects in one step — you call a method and get back a fully constructed object. A Builder constructs complex objects step by step, letting you set optional fields before calling build(). Use Factory for simple creation, Builder when construction requires many optional parameters.
What is the best way to implement a Singleton in Java?
A single-element enum is the simplest thread-safe singleton and is also safe against reflection and serialization tricks. The initialization-on-demand holder idiom is the usual choice when the singleton must extend a class or be created lazily; both rely on the JVM's guarantee that class initialisation happens exactly once.