Skip to content

05 · Design Patterns in Java

Design patterns are named, reusable solutions to recurring design problems. They're not libraries to import — they're shapes of code you'll recognize (and reach for) once you've seen them. This module covers four of the most common: Factory, Singleton, Observer, and Strategy.

Factory — centralizing object creation

A factory hides the details of which concrete class to instantiate behind a single creation method, so callers depend on an interface, not a constructor.

public interface Shape {
    double area();
}

public class Circle implements Shape {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }
    @Override public double area() { return Math.PI * radius * radius; }
}

public class Square implements Shape {
    private final double side;
    public Square(double side) { this.side = side; }
    @Override public double area() { return side * side; }
}

public class ShapeFactory {
    public static Shape create(String type, double size) {
        return switch (type.toLowerCase()) {
            case "circle" -> new Circle(size);
            case "square" -> new Square(size);
            default -> throw new IllegalArgumentException("Unknown shape: " + type);
        };
    }
}

public class FactoryDemo {
    public static void main(String[] args) {
        Shape s1 = ShapeFactory.create("circle", 3);
        Shape s2 = ShapeFactory.create("square", 4);

        System.out.printf("Circle area: %.2f%n", s1.area());   // Circle area: 28.27
        System.out.printf("Square area: %.2f%n", s2.area());   // Square area: 16.00
    }
}

Callers never write new Circle(...) directly — if a new shape type is added later, only ShapeFactory needs to change.

Singleton — exactly one instance

A singleton guarantees a class has only one instance, globally accessible. The classic approach uses a private constructor and a static instance:

public class ConfigManager {
    private static ConfigManager instance;
    private final java.util.Map<String, String> settings = new java.util.HashMap<>();

    private ConfigManager() { }   // private -- no one else can call "new ConfigManager()"

    public static synchronized ConfigManager getInstance() {
        if (instance == null) {
            instance = new ConfigManager();
        }
        return instance;
    }

    public void set(String key, String value) { settings.put(key, value); }
    public String get(String key) { return settings.get(key); }
}

ConfigManager.getInstance().set("env", "production");
System.out.println(ConfigManager.getInstance().get("env"));   // production

The modern, recommended approach in Java is an enum singleton — it's thread-safe by construction, serialization-safe, and immune to reflection attacks that can otherwise break the classic pattern:

public enum AppConfig {
    INSTANCE;

    private final java.util.Map<String, String> settings = new java.util.HashMap<>();

    public void set(String key, String value) { settings.put(key, value); }
    public String get(String key) { return settings.get(key); }
}

AppConfig.INSTANCE.set("env", "production");
System.out.println(AppConfig.INSTANCE.get("env"));   // production

Observer — reacting to events

The observer pattern lets a subject notify a list of interested listeners whenever something happens, without the subject knowing anything about them beyond a common interface.

import java.util.ArrayList;
import java.util.List;

public interface OrderListener {
    void onOrderPlaced(String item);
}

public class OrderService {
    private final List<OrderListener> listeners = new ArrayList<>();

    public void addListener(OrderListener listener) {
        listeners.add(listener);
    }

    public void placeOrder(String item) {
        System.out.println("Order placed: " + item);
        for (OrderListener listener : listeners) {
            listener.onOrderPlaced(item);   // notify every registered observer
        }
    }
}

public class ObserverDemo {
    public static void main(String[] args) {
        OrderService orders = new OrderService();

        orders.addListener(item -> System.out.println("Email: your order for " + item + " is confirmed"));
        orders.addListener(item -> System.out.println("Inventory: reserving stock for " + item));

        orders.placeOrder("Java Mastery Path Book");
        // Order placed: Java Mastery Path Book
        // Email: your order for Java Mastery Path Book is confirmed
        // Inventory: reserving stock for Java Mastery Path Book
    }
}

Strategy — swappable algorithms

The strategy pattern extracts an algorithm behind an interface so it can be swapped at runtime, avoiding long if/switch chains scattered through the codebase.

public interface DiscountStrategy {
    double apply(double price);
}

public class NoDiscount implements DiscountStrategy {
    @Override public double apply(double price) { return price; }
}

public class PercentageDiscount implements DiscountStrategy {
    private final double percent;
    public PercentageDiscount(double percent) { this.percent = percent; }
    @Override public double apply(double price) { return price * (1 - percent / 100); }
}

public class FlatDiscount implements DiscountStrategy {
    private final double amount;
    public FlatDiscount(double amount) { this.amount = amount; }
    @Override public double apply(double price) { return Math.max(0, price - amount); }
}

public class Checkout {
    private final DiscountStrategy strategy;

    public Checkout(DiscountStrategy strategy) {
        this.strategy = strategy;
    }

    public double total(double price) {
        return strategy.apply(price);
    }
}

public class StrategyDemo {
    public static void main(String[] args) {
        Checkout regular = new Checkout(new NoDiscount());
        Checkout memberDay = new Checkout(new PercentageDiscount(20));
        Checkout coupon = new Checkout(new FlatDiscount(5));

        System.out.printf("Regular: $%.2f%n", regular.total(50));      // Regular: $50.00
        System.out.printf("Member day: $%.2f%n", memberDay.total(50)); // Member day: $40.00
        System.out.printf("Coupon: $%.2f%n", coupon.total(50));        // Coupon: $45.00
    }
}

The Checkout class never changes when a new discount type is added — it just depends on the DiscountStrategy interface.

Pattern Problem it solves Key idea
Factory Which concrete class to instantiate Centralize creation behind one method/interface
Singleton Ensure exactly one shared instance Private constructor + controlled global access point
Observer Notify many interested parties of an event Subject holds a list of listener interfaces
Strategy Swappable algorithms without conditionals Extract behavior behind an interface, inject the implementation

How It Actually Works

Every pattern built around polymorphic dispatch (Strategy, Observer, Template Method, Decorator) is ultimately leaning on the same JVM mechanism: invokevirtual/invokeinterface resolving through a vtable/itable at the call site based on the object's actual runtime class, not its declared type. The pattern's "magic" — swapping behavior by swapping an object — is just this dispatch mechanism used deliberately.

Singleton implemented as an enum (the recommended form since Effective Java) isn't a style preference — it's the only approach the JVM guarantees is safe against both reflection-based re-instantiation (the JVM's serialization/deserialization and reflection machinery treat enum constants specially and refuse to construct duplicates) and double-checked-locking's classic Java Memory Model pitfall (a non-volatile lazily-initialized singleton reference can be observed partially-constructed by another thread due to reordering, unless the field is volatile — establishing the happens-before edge the JMM requires).

Proxy-based patterns (Decorator done via dynamic proxy, or how Spring AOP implements method interception) use java.lang.reflect.Proxy, which generates an actual bytecode-level class at runtime implementing the target interfaces and routing every call through your InvocationHandler — real class generation and loading, not interpretation, which is why the first call through a fresh proxy pays a one-time class-generation cost.

Exercise

Implement the Strategy pattern for a SortingStrategy interface with a single method int[] sort(int[] data). Provide two implementations — BubbleSortStrategy and BuiltInSortStrategy (using Arrays.sort) — and a Sorter class that takes a SortingStrategy in its constructor and exposes a sort method delegating to it. Demonstrate swapping strategies at runtime on the same unsorted array.