Java Design Patterns: Strategy, Observer, Factory & More

Design patterns are proven solutions to recurring software design problems. This article covers the seven most essential patterns every Java developer should know: each shown with a modern Java implementation using records, sealed classes, lambdas, and functional interfaces where appropriate.

Contents

Strategy Pattern

The Strategy pattern defines a family of interchangeable algorithms and lets you swap them at runtime. In modern Java, functional interfaces and lambdas make this pattern incredibly concise: you often don't even need separate strategy classes.

Classic approach: interface + implementations
Java
// Strategy interface
public interface PricingStrategy {
    double calculatePrice(double basePrice, int quantity);
}

// Concrete strategies
public class RegularPricing implements PricingStrategy {
    @Override
    public double calculatePrice(double basePrice, int quantity) {
        return basePrice * quantity;
    }
}

public class BulkPricing implements PricingStrategy {
    @Override
    public double calculatePrice(double basePrice, int quantity) {
        double discount = quantity >= 100 ? 0.20 : quantity >= 50 ? 0.10 : 0.0;
        return basePrice * quantity * (1 - discount);
    }
}

public class SeasonalPricing implements PricingStrategy {
    private final double seasonalDiscount;

    public SeasonalPricing(double seasonalDiscount) {
        this.seasonalDiscount = seasonalDiscount;
    }

    @Override
    public double calculatePrice(double basePrice, int quantity) {
        return basePrice * quantity * (1 - seasonalDiscount);
    }
}
Context class
Java
public class OrderProcessor {

    private PricingStrategy strategy;

    public OrderProcessor(PricingStrategy strategy) {
        this.strategy = strategy;
    }

    public void setStrategy(PricingStrategy strategy) {
        this.strategy = strategy;
    }

    public double processOrder(String product, double basePrice, int quantity) {
        double total = strategy.calculatePrice(basePrice, quantity);
        System.out.printf("Order: %d x %s = $%.2f%n", quantity, product, total);
        return total;
    }
}

// Usage
OrderProcessor processor = new OrderProcessor(new RegularPricing());
processor.processOrder("Widget", 10.0, 5);       // $50.00

processor.setStrategy(new BulkPricing());
processor.processOrder("Widget", 10.0, 100);      // $800.00 (20% off)
Modern approach: lambdas

Since PricingStrategy is a functional interface (single abstract method), you can pass lambdas directly:

Java
// No separate classes needed
OrderProcessor processor = new OrderProcessor(
    (base, qty) -> base * qty * 0.85  // 15% off
);

// Or use method references
public class PricingStrategies {
    public static double premium(double base, int qty) {
        return base * qty * 1.25;  // 25% markup
    }
}

OrderProcessor vip = new OrderProcessor(PricingStrategies::premium);
Use the classic approach when strategies carry state or have multiple methods. Use lambdas when the strategy is a single stateless computation.

Observer Pattern

The Observer pattern establishes a one-to-many dependency: when a subject changes state, all registered observers are notified automatically. This is the foundation of event-driven systems, UI listeners, and reactive streams.

Java
// Observer interface
@FunctionalInterface
public interface EventListener<T> {
    void onEvent(T event);
}

// Event types using sealed interface + records
public sealed interface OrderEvent permits
        OrderEvent.Created, OrderEvent.Shipped, OrderEvent.Cancelled {

    record Created(String orderId, double amount) implements OrderEvent {}
    record Shipped(String orderId, String trackingNo) implements OrderEvent {}
    record Cancelled(String orderId, String reason) implements OrderEvent {}
}
Event emitter (Subject)
Java
public class EventBus<T> {

    private final Map<Class<?>, List<EventListener<?>>> listeners = new ConcurrentHashMap<>();

    @SuppressWarnings("unchecked")
    public <E extends T> void subscribe(Class<E> eventType, EventListener<E> listener) {
        listeners.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>())
                 .add(listener);
    }

    @SuppressWarnings("unchecked")
    public <E extends T> void publish(E event) {
        List<EventListener<?>> handlers = listeners.getOrDefault(event.getClass(), List.of());
        for (EventListener<?> handler : handlers) {
            ((EventListener<E>) handler).onEvent(event);
        }
    }
}
Usage
Java
EventBus<OrderEvent> bus = new EventBus<>();

// Subscribe with lambdas
bus.subscribe(OrderEvent.Created.class, e ->
    System.out.println("New order: " + e.orderId() + " for $" + e.amount()));

bus.subscribe(OrderEvent.Shipped.class, e ->
    System.out.println("Shipped: " + e.orderId() + " tracking: " + e.trackingNo()));

bus.subscribe(OrderEvent.Cancelled.class, e ->
    System.out.println("Cancelled: " + e.orderId() + " reason: " + e.reason()));

// Publish events
bus.publish(new OrderEvent.Created("ORD-001", 99.99));
bus.publish(new OrderEvent.Shipped("ORD-001", "TRK-12345"));
In Spring applications, prefer Spring's built-in ApplicationEventPublisher and @EventListener annotation over a custom Observer implementation.

Factory Method Pattern

The Factory Method pattern delegates object creation to subclasses or static methods, hiding construction complexity behind a clean API. Modern Java makes this especially elegant with sealed interfaces and records.

Java
// Product hierarchy using sealed interfaces
public sealed interface Notification permits
        Notification.Email, Notification.Sms, Notification.Push {

    String recipient();
    String message();
    void send();

    record Email(String recipient, String message, String subject) implements Notification {
        @Override
        public void send() {
            System.out.printf("Email to %s [%s]: %s%n", recipient, subject, message);
        }
    }

    record Sms(String recipient, String message) implements Notification {
        @Override
        public void send() {
            System.out.printf("SMS to %s: %s%n", recipient, message);
        }
    }

    record Push(String recipient, String message, String appId) implements Notification {
        @Override
        public void send() {
            System.out.printf("Push [%s] to %s: %s%n", appId, recipient, message);
        }
    }
}
Factory with enum-based selection
Java
public class NotificationFactory {

    public enum Channel { EMAIL, SMS, PUSH }

    public static Notification create(Channel channel, String recipient, String message) {
        return switch (channel) {
            case EMAIL -> new Notification.Email(recipient, message, "Notification");
            case SMS   -> new Notification.Sms(recipient, message);
            case PUSH  -> new Notification.Push(recipient, message, "com.myapp");
        };
    }
}

// Usage
Notification n = NotificationFactory.create(Channel.EMAIL, "alice@example.com", "Hello!");
n.send();  // Email to alice@example.com [Notification]: Hello!
Registry-based factory (extensible)
Java
public class NotificationRegistry {

    private final Map<String, Function<Map<String, String>, Notification>> creators
            = new HashMap<>();

    public void register(String type, Function<Map<String, String>, Notification> creator) {
        creators.put(type.toLowerCase(), creator);
    }

    public Notification create(String type, Map<String, String> params) {
        var creator = creators.get(type.toLowerCase());
        if (creator == null) {
            throw new IllegalArgumentException("Unknown notification type: " + type);
        }
        return creator.apply(params);
    }
}

// Register creators at startup
NotificationRegistry registry = new NotificationRegistry();
registry.register("email", p ->
    new Notification.Email(p.get("to"), p.get("body"), p.getOrDefault("subject", "Info")));
registry.register("sms", p ->
    new Notification.Sms(p.get("to"), p.get("body")));

Builder Pattern

The Builder pattern constructs complex objects step by step, letting you create different configurations through the same construction process. It shines when objects have many optional parameters.

Java
public class HttpRequest {

    private final String method;
    private final String url;
    private final Map<String, String> headers;
    private final String body;
    private final Duration timeout;
    private final int maxRetries;

    private HttpRequest(Builder builder) {
        this.method     = builder.method;
        this.url        = builder.url;
        this.headers    = Map.copyOf(builder.headers);
        this.body       = builder.body;
        this.timeout    = builder.timeout;
        this.maxRetries = builder.maxRetries;
    }

    // Getters
    public String method()             { return method; }
    public String url()                { return url; }
    public Map<String, String> headers() { return headers; }
    public String body()               { return body; }
    public Duration timeout()          { return timeout; }
    public int maxRetries()            { return maxRetries; }

    public static Builder builder(String method, String url) {
        return new Builder(method, url);
    }

    public static class Builder {
        // Required
        private final String method;
        private final String url;

        // Optional with defaults
        private final Map<String, String> headers = new LinkedHashMap<>();
        private String body;
        private Duration timeout = Duration.ofSeconds(30);
        private int maxRetries = 0;

        private Builder(String method, String url) {
            this.method = Objects.requireNonNull(method);
            this.url    = Objects.requireNonNull(url);
        }

        public Builder header(String name, String value) {
            headers.put(name, value);
            return this;
        }

        public Builder body(String body) {
            this.body = body;
            return this;
        }

        public Builder timeout(Duration timeout) {
            this.timeout = timeout;
            return this;
        }

        public Builder maxRetries(int maxRetries) {
            if (maxRetries < 0) throw new IllegalArgumentException("maxRetries must be >= 0");
            this.maxRetries = maxRetries;
            return this;
        }

        public HttpRequest build() {
            return new HttpRequest(this);
        }
    }
}
Fluent usage
Java
HttpRequest request = HttpRequest.builder("POST", "https://api.example.com/users")
        .header("Content-Type", "application/json")
        .header("Authorization", "Bearer token123")
        .body("{\"name\": \"Alice\"}")
        .timeout(Duration.ofSeconds(10))
        .maxRetries(3)
        .build();

System.out.println(request.method() + " " + request.url());
// POST https://api.example.com/users
Make the outer class constructor private and store fields as unmodifiable copies (Map.copyOf, List.copyOf) to ensure the built object is immutable.

Decorator Pattern

The Decorator pattern wraps objects to add behavior dynamically without modifying the original class. Each decorator implements the same interface and delegates to the wrapped instance, adding its own logic before or after.

Java
// Base interface
public interface DataSource {
    void writeData(String data);
    String readData();
}

// Core implementation
public class FileDataSource implements DataSource {
    private final String filename;
    private String content = "";

    public FileDataSource(String filename) {
        this.filename = filename;
    }

    @Override
    public void writeData(String data) {
        this.content = data;
        System.out.println("Written to " + filename);
    }

    @Override
    public String readData() {
        return content;
    }
}
Decorators
Java
// Base decorator
public abstract class DataSourceDecorator implements DataSource {
    protected final DataSource wrapped;

    protected DataSourceDecorator(DataSource wrapped) {
        this.wrapped = wrapped;
    }

    @Override
    public void writeData(String data) { wrapped.writeData(data); }

    @Override
    public String readData() { return wrapped.readData(); }
}

// Encryption decorator
public class EncryptionDecorator extends DataSourceDecorator {

    public EncryptionDecorator(DataSource wrapped) {
        super(wrapped);
    }

    @Override
    public void writeData(String data) {
        String encrypted = Base64.getEncoder().encodeToString(data.getBytes());
        System.out.println("Encrypting data...");
        super.writeData(encrypted);
    }

    @Override
    public String readData() {
        String data = super.readData();
        return new String(Base64.getDecoder().decode(data));
    }
}

// Compression decorator
public class CompressionDecorator extends DataSourceDecorator {

    public CompressionDecorator(DataSource wrapped) {
        super(wrapped);
    }

    @Override
    public void writeData(String data) {
        String compressed = "[compressed:" + data.length() + "]" + data;
        System.out.println("Compressing data...");
        super.writeData(compressed);
    }

    @Override
    public String readData() {
        String data = super.readData();
        // Strip compression header
        return data.replaceFirst("\\[compressed:\\d+]", "");
    }
}
Composing decorators
Java
// Stack decorators — order matters
DataSource source = new EncryptionDecorator(
                        new CompressionDecorator(
                            new FileDataSource("data.txt")));

source.writeData("Sensitive information");
// Output:
//   Compressing data...
//   Encrypting data...
//   Written to data.txt

String result = source.readData();
System.out.println(result);  // Sensitive information
Java's InputStream / OutputStream family (BufferedInputStream, GZIPOutputStream, etc.) is the classic real-world example of the Decorator pattern in the JDK.

Singleton Pattern

The Singleton pattern ensures a class has exactly one instance and provides a global access point to it. While overused in the past, it remains valid for stateless services, connection pools, and configuration holders.

Enum singleton (recommended)

The simplest and most robust approach: the JVM guarantees exactly one instance, handles serialization, and prevents reflection attacks:

Java
public enum AppConfig {
    INSTANCE;

    private final Properties properties = new Properties();

    AppConfig() {
        try (var input = getClass().getClassLoader()
                .getResourceAsStream("application.properties")) {
            if (input != null) properties.load(input);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    public String get(String key) {
        return properties.getProperty(key);
    }

    public String get(String key, String defaultValue) {
        return properties.getProperty(key, defaultValue);
    }
}

// Usage
String dbUrl = AppConfig.INSTANCE.get("db.url");
Lazy initialization with holder class

Thread-safe without synchronization: the JVM guarantees the inner class is loaded only when first accessed:

Java
public class ConnectionPool {

    private ConnectionPool() {
        System.out.println("Pool initialized");
    }

    // Inner class not loaded until getInstance() is called
    private static class Holder {
        static final ConnectionPool INSTANCE = new ConnectionPool();
    }

    public static ConnectionPool getInstance() {
        return Holder.INSTANCE;
    }

    public Connection getConnection() {
        // return connection from pool...
        return null;
    }
}
Avoid Singleton for mutable shared state: it creates hidden global dependencies and makes unit testing difficult. In Spring applications, use @Component with default singleton scope instead of hand-rolling the pattern.

Template Method Pattern

The Template Method pattern defines the skeleton of an algorithm in a base class, deferring specific steps to subclasses. The base class controls the overall flow; subclasses customize individual steps without changing the structure.

Java
public abstract class DataProcessor {

    // Template method — defines the algorithm skeleton (final = can't be overridden)
    public final void process(String source) {
        String raw = readData(source);
        String cleaned = cleanData(raw);
        String transformed = transformData(cleaned);
        saveData(transformed);
        System.out.println("Processing complete for: " + source);
    }

    // Steps to be implemented by subclasses
    protected abstract String readData(String source);
    protected abstract String transformData(String data);

    // Hook methods — optional overrides with default behavior
    protected String cleanData(String data) {
        return data.strip();
    }

    protected void saveData(String data) {
        System.out.println("Saving: " + data.substring(0, Math.min(50, data.length())) + "...");
    }
}
Concrete implementations
Java
public class CsvProcessor extends DataProcessor {

    @Override
    protected String readData(String source) {
        System.out.println("Reading CSV from: " + source);
        return "name,age,city\nAlice,30,NYC\nBob,25,LA";
    }

    @Override
    protected String transformData(String data) {
        // Convert CSV to uppercase
        return data.toUpperCase();
    }
}

public class JsonProcessor extends DataProcessor {

    @Override
    protected String readData(String source) {
        System.out.println("Reading JSON from: " + source);
        return "{\"users\": [{\"name\": \"Alice\"}, {\"name\": \"Bob\"}]}";
    }

    @Override
    protected String transformData(String data) {
        // Add metadata wrapper
        return "{\"metadata\": {\"processed\": true}, \"data\": " + data + "}";
    }

    @Override
    protected void saveData(String data) {
        System.out.println("Saving JSON to database: " + data.substring(0, 50) + "...");
    }
}

// Usage
DataProcessor csv = new CsvProcessor();
csv.process("data.csv");

DataProcessor json = new JsonProcessor();
json.process("api/users");
The Template Method pattern is used extensively in frameworks: Spring's AbstractController, JUnit's test lifecycle hooks, and HttpServlet's doGet/doPost methods all follow this pattern.

Choosing the Right Pattern

Use this quick reference to pick the right pattern for your scenario:

Java
/*
  Problem                              Pattern             Modern Java Shortcut
  ─────────────────────────────────────────────────────────────────────────────────
  Swap algorithms at runtime           Strategy            Lambda / method reference
  Notify multiple listeners            Observer            Spring @EventListener
  Hide complex object creation         Factory Method      Sealed interface + switch
  Build objects with many options       Builder             Records for simple cases
  Add behavior without subclassing     Decorator           Function composition
  Ensure one instance                  Singleton           Enum or @Component
  Define algorithm skeleton            Template Method     Abstract class + final method
  ─────────────────────────────────────────────────────────────────────────────────

  Rules of thumb:
  1. Prefer composition over inheritance (Strategy, Decorator, Observer)
  2. Use lambdas to eliminate boilerplate strategy/listener classes
  3. Use sealed interfaces + records to make Factory hierarchies exhaustive
  4. In Spring apps, the framework already implements many patterns for you:
     - Singleton → default bean scope
     - Observer  → ApplicationEventPublisher
     - Strategy  → inject List<Strategy> and pick at runtime
     - Template  → JdbcTemplate, RestTemplate
*/