Java Design Patterns

Design patterns are reusable solutions to common programming problems. They’re not code you copy and paste, but templates for structuring your classes and objects. Learning patterns helps you recognize problems faster and communicate solutions clearly with other developers.

This guide covers the patterns you’ll encounter most often in Java applications, with practical examples showing when and how to use each one.

Why Patterns Matter

Patterns provide a shared vocabulary. When you say “that’s a Singleton” or “we need a Factory here,” experienced developers immediately understand the structure you’re proposing. This speeds up design discussions and code reviews.

Patterns also encode decades of collective experience. The Gang of Four (GoF) documented 23 patterns in 1994, and they remain relevant because the problems they solve keep appearing in new projects.

Pattern Categories

The classic patterns fall into three groups:

  • Creational: How objects are created (Singleton, Factory, Builder)
  • Structural: How objects are composed (Adapter, Decorator, Facade)
  • Behavioral: How objects interact (Strategy, Observer, Template Method)

Creational Patterns

Singleton

Ensures only one instance of a class exists throughout the application.

When to use: Configuration managers, connection pools, logging services, caches.

public class DatabaseConnection {
    private static volatile DatabaseConnection instance;
    private Connection connection;
    
    private DatabaseConnection() {
        // Private constructor prevents direct instantiation
        this.connection = createConnection();
    }
    
    public static DatabaseConnection getInstance() {
        if (instance == null) {
            synchronized (DatabaseConnection.class) {
                if (instance == null) {
                    instance = new DatabaseConnection();
                }
            }
        }
        return instance;
    }
    
    public Connection getConnection() {
        return connection;
    }
    
    private Connection createConnection() {
        // Create database connection
        return null;
    }
}

// Usage
DatabaseConnection db = DatabaseConnection.getInstance();
Connection conn = db.getConnection();

The double-checked locking with volatile ensures thread safety without synchronizing every access.

Modern alternative: Use an enum for simpler, thread-safe singletons:

public enum AppConfig {
    INSTANCE;
    
    private String apiUrl;
    private int timeout;
    
    AppConfig() {
        // Load configuration
        this.apiUrl = "https://api.example.com";
        this.timeout = 30;
    }
    
    public String getApiUrl() { return apiUrl; }
    public int getTimeout() { return timeout; }
}

// Usage
String url = AppConfig.INSTANCE.getApiUrl();

Factory Method

Defines an interface for creating objects but lets subclasses decide which class to instantiate.

When to use: When the exact type of object depends on input or configuration.

// Product interface
public interface Notification {
    void send(String message);
}

// Concrete products
public class EmailNotification implements Notification {
    @Override
    public void send(String message) {
        System.out.println("Sending email: " + message);
    }
}

public class SmsNotification implements Notification {
    @Override
    public void send(String message) {
        System.out.println("Sending SMS: " + message);
    }
}

public class PushNotification implements Notification {
    @Override
    public void send(String message) {
        System.out.println("Sending push: " + message);
    }
}

// Factory
public class NotificationFactory {
    public static Notification createNotification(String type) {
        switch (type.toLowerCase()) {
            case "email":
                return new EmailNotification();
            case "sms":
                return new SmsNotification();
            case "push":
                return new PushNotification();
            default:
                throw new IllegalArgumentException("Unknown type: " + type);
        }
    }
}

// Usage
Notification notification = NotificationFactory.createNotification("email");
notification.send("Hello!");

The client code doesn’t need to know concrete classes. It works with the Notification interface and lets the factory handle instantiation.

Builder

Separates the construction of a complex object from its representation.

When to use: Objects with many optional parameters, immutable objects that need step-by-step construction.

public class HttpRequest {
    private final String url;
    private final String method;
    private final Map<String, String> headers;
    private final String body;
    private final int timeout;
    
    private HttpRequest(Builder builder) {
        this.url = builder.url;
        this.method = builder.method;
        this.headers = builder.headers;
        this.body = builder.body;
        this.timeout = builder.timeout;
    }
    
    public static class Builder {
        private final String url;  // Required
        private String method = "GET";
        private Map<String, String> headers = new HashMap<>();
        private String body = null;
        private int timeout = 30;
        
        public Builder(String url) {
            this.url = url;
        }
        
        public Builder method(String method) {
            this.method = method;
            return this;
        }
        
        public Builder header(String key, String value) {
            this.headers.put(key, value);
            return this;
        }
        
        public Builder body(String body) {
            this.body = body;
            return this;
        }
        
        public Builder timeout(int timeout) {
            this.timeout = timeout;
            return this;
        }
        
        public HttpRequest build() {
            return new HttpRequest(this);
        }
    }
    
    // Getters
    public String getUrl() { return url; }
    public String getMethod() { return method; }
    public Map<String, String> getHeaders() { return headers; }
    public String getBody() { return body; }
    public int getTimeout() { return timeout; }
}

// Usage - fluent API
HttpRequest request = new HttpRequest.Builder("https://api.example.com/users")
    .method("POST")
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer token123")
    .body("{\"name\": \"Alice\"}")
    .timeout(60)
    .build();

The builder pattern makes object construction readable and allows for immutable objects with many optional fields.

Structural Patterns

Adapter

Converts the interface of a class into another interface that clients expect.

When to use: Integrating legacy code, third-party libraries, or incompatible interfaces.

// Existing interface your code expects
public interface MediaPlayer {
    void play(String filename);
}

// Third-party library with different interface
public class AdvancedMediaLibrary {
    public void playMp4(String filename) {
        System.out.println("Playing MP4: " + filename);
    }
    
    public void playVlc(String filename) {
        System.out.println("Playing VLC: " + filename);
    }
}

// Adapter makes the library work with your interface
public class MediaAdapter implements MediaPlayer {
    private AdvancedMediaLibrary advancedPlayer;
    
    public MediaAdapter() {
        this.advancedPlayer = new AdvancedMediaLibrary();
    }
    
    @Override
    public void play(String filename) {
        if (filename.endsWith(".mp4")) {
            advancedPlayer.playMp4(filename);
        } else if (filename.endsWith(".vlc")) {
            advancedPlayer.playVlc(filename);
        } else {
            System.out.println("Unsupported format");
        }
    }
}

// Usage - client code works with MediaPlayer interface
MediaPlayer player = new MediaAdapter();
player.play("movie.mp4");

Decorator

Adds behavior to objects dynamically without affecting other objects of the same class.

When to use: Adding features to objects at runtime, when subclassing would create too many combinations.

// Component interface
public interface Coffee {
    String getDescription();
    double getCost();
}

// Base implementation
public class SimpleCoffee implements Coffee {
    @Override
    public String getDescription() {
        return "Coffee";
    }
    
    @Override
    public double getCost() {
        return 2.00;
    }
}

// Decorator base class
public abstract class CoffeeDecorator implements Coffee {
    protected Coffee coffee;
    
    public CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }
}

// Concrete decorators
public class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee coffee) {
        super(coffee);
    }
    
    @Override
    public String getDescription() {
        return coffee.getDescription() + ", Milk";
    }
    
    @Override
    public double getCost() {
        return coffee.getCost() + 0.50;
    }
}

public class SugarDecorator extends CoffeeDecorator {
    public SugarDecorator(Coffee coffee) {
        super(coffee);
    }
    
    @Override
    public String getDescription() {
        return coffee.getDescription() + ", Sugar";
    }
    
    @Override
    public double getCost() {
        return coffee.getCost() + 0.25;
    }
}

// Usage - decorators can be stacked
Coffee coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);

System.out.println(coffee.getDescription());  // "Coffee, Milk, Sugar"
System.out.println(coffee.getCost());         // 2.75

Java’s I/O streams use this pattern extensively: BufferedReader decorates FileReader, which decorates a file stream.

Facade

Provides a simplified interface to a complex subsystem.

When to use: Hiding complexity, providing a clean API for a library or module.

// Complex subsystem classes
public class VideoDecoder {
    public void decode(String filename) {
        System.out.println("Decoding video: " + filename);
    }
}

public class AudioDecoder {
    public void decode(String filename) {
        System.out.println("Decoding audio: " + filename);
    }
}

public class VideoRenderer {
    public void render(String videoData) {
        System.out.println("Rendering video");
    }
}

public class AudioPlayer {
    public void play(String audioData) {
        System.out.println("Playing audio");
    }
}

// Facade provides simple interface
public class MediaPlayerFacade {
    private VideoDecoder videoDecoder;
    private AudioDecoder audioDecoder;
    private VideoRenderer videoRenderer;
    private AudioPlayer audioPlayer;
    
    public MediaPlayerFacade() {
        this.videoDecoder = new VideoDecoder();
        this.audioDecoder = new AudioDecoder();
        this.videoRenderer = new VideoRenderer();
        this.audioPlayer = new AudioPlayer();
    }
    
    public void playVideo(String filename) {
        videoDecoder.decode(filename);
        audioDecoder.decode(filename);
        videoRenderer.render("decoded video");
        audioPlayer.play("decoded audio");
    }
}

// Usage - client only knows the facade
MediaPlayerFacade player = new MediaPlayerFacade();
player.playVideo("movie.mp4");

Behavioral Patterns

Strategy

Defines a family of algorithms and makes them interchangeable.

When to use: Multiple ways to perform an operation, algorithm selection at runtime.

// Strategy interface
public interface PaymentStrategy {
    void pay(double amount);
}

// Concrete strategies
public class CreditCardPayment implements PaymentStrategy {
    private String cardNumber;
    
    public CreditCardPayment(String cardNumber) {
        this.cardNumber = cardNumber;
    }
    
    @Override
    public void pay(double amount) {
        System.out.println("Paid $" + amount + " with credit card " + 
            cardNumber.substring(cardNumber.length() - 4));
    }
}

public class PayPalPayment implements PaymentStrategy {
    private String email;
    
    public PayPalPayment(String email) {
        this.email = email;
    }
    
    @Override
    public void pay(double amount) {
        System.out.println("Paid $" + amount + " via PayPal: " + email);
    }
}

public class CryptoPayment implements PaymentStrategy {
    private String walletAddress;
    
    public CryptoPayment(String walletAddress) {
        this.walletAddress = walletAddress;
    }
    
    @Override
    public void pay(double amount) {
        System.out.println("Paid $" + amount + " in crypto to " + 
            walletAddress.substring(0, 8) + "...");
    }
}

// Context class
public class ShoppingCart {
    private List<Double> items = new ArrayList<>();
    private PaymentStrategy paymentStrategy;
    
    public void addItem(double price) {
        items.add(price);
    }
    
    public void setPaymentStrategy(PaymentStrategy strategy) {
        this.paymentStrategy = strategy;
    }
    
    public void checkout() {
        double total = items.stream().mapToDouble(Double::doubleValue).sum();
        paymentStrategy.pay(total);
    }
}

// Usage
ShoppingCart cart = new ShoppingCart();
cart.addItem(29.99);
cart.addItem(49.99);

cart.setPaymentStrategy(new CreditCardPayment("1234-5678-9012-3456"));
cart.checkout();  // Paid $79.98 with credit card 3456

cart.setPaymentStrategy(new PayPalPayment("user@example.com"));
cart.checkout();  // Paid $79.98 via PayPal: user@example.com

Observer

Defines a one-to-many dependency so that when one object changes state, all dependents are notified.

When to use: Event handling, notifications, keeping distributed data in sync.

// Observer interface
public interface StockObserver {
    void update(String stock, double price);
}

// Subject (Observable)
public class StockMarket {
    private Map<String, Double> stocks = new HashMap<>();
    private List<StockObserver> observers = new ArrayList<>();
    
    public void addObserver(StockObserver observer) {
        observers.add(observer);
    }
    
    public void removeObserver(StockObserver observer) {
        observers.remove(observer);
    }
    
    public void setStockPrice(String stock, double price) {
        stocks.put(stock, price);
        notifyObservers(stock, price);
    }
    
    private void notifyObservers(String stock, double price) {
        for (StockObserver observer : observers) {
            observer.update(stock, price);
        }
    }
}

// Concrete observers
public class StockDisplay implements StockObserver {
    private String name;
    
    public StockDisplay(String name) {
        this.name = name;
    }
    
    @Override
    public void update(String stock, double price) {
        System.out.println(name + " - " + stock + ": $" + price);
    }
}

public class PriceAlert implements StockObserver {
    private String targetStock;
    private double threshold;
    
    public PriceAlert(String stock, double threshold) {
        this.targetStock = stock;
        this.threshold = threshold;
    }
    
    @Override
    public void update(String stock, double price) {
        if (stock.equals(targetStock) && price > threshold) {
            System.out.println("ALERT: " + stock + " exceeded $" + threshold);
        }
    }
}

// Usage
StockMarket market = new StockMarket();
market.addObserver(new StockDisplay("Main Board"));
market.addObserver(new PriceAlert("AAPL", 150.00));

market.setStockPrice("AAPL", 155.00);
// Output:
// Main Board - AAPL: $155.0
// ALERT: AAPL exceeded $150.0

Template Method

Defines the skeleton of an algorithm, letting subclasses override specific steps.

When to use: When multiple classes share the same algorithm structure but differ in specific steps.

// Abstract class with template method
public abstract class DataProcessor {
    
    // Template method - defines the algorithm structure
    public final void process() {
        readData();
        processData();
        writeData();
    }
    
    protected abstract void readData();
    protected abstract void processData();
    
    // Hook method - optional override
    protected void writeData() {
        System.out.println("Writing to default output");
    }
}

// Concrete implementations
public class CsvProcessor extends DataProcessor {
    @Override
    protected void readData() {
        System.out.println("Reading CSV file");
    }
    
    @Override
    protected void processData() {
        System.out.println("Parsing CSV rows");
    }
}

public class JsonProcessor extends DataProcessor {
    @Override
    protected void readData() {
        System.out.println("Reading JSON file");
    }
    
    @Override
    protected void processData() {
        System.out.println("Parsing JSON objects");
    }
    
    @Override
    protected void writeData() {
        System.out.println("Writing to JSON output");
    }
}

// Usage
DataProcessor csvProcessor = new CsvProcessor();
csvProcessor.process();
// Reading CSV file
// Parsing CSV rows
// Writing to default output

DataProcessor jsonProcessor = new JsonProcessor();
jsonProcessor.process();
// Reading JSON file
// Parsing JSON objects
// Writing to JSON output

Patterns in the Java Standard Library

You’ve already used patterns without knowing it:

Pattern Java Example
Singleton Runtime.getRuntime()
Factory Calendar.getInstance(), NumberFormat.getInstance()
Builder StringBuilder, Stream.Builder
Adapter Arrays.asList(), InputStreamReader
Decorator BufferedReader, Collections.synchronizedList()
Strategy Comparator, LayoutManager
Observer ActionListener, PropertyChangeListener
Iterator Iterator interface, enhanced for loop
Template Method HttpServlet.doGet(), AbstractList

When Not to Use Patterns

Patterns solve specific problems. Using them unnecessarily adds complexity.

Don’t use patterns when:

  • Simple code works fine
  • You’re solving a problem the pattern wasn’t designed for
  • The pattern adds more code than it saves
  • You’re using a pattern just to use a pattern

Start simple. Refactor to patterns when the need becomes clear. Premature abstraction is as harmful as no abstraction.

Summary

Master these patterns and you’ll recognize them everywhere: in frameworks, libraries, and codebases you inherit. More importantly, you’ll know when and how to apply them yourself.

Start with the patterns you’ll use most: Builder for complex objects, Factory for flexible instantiation, Strategy for swappable algorithms, and Observer for event handling. Add others as you encounter problems they solve.


Prerequisites: Introduction to Object-Oriented Programming | Java Interfaces | Abstract Classes in Java

Related: Java Collections Framework | Introduction to Spring Boot

Sources

  • Gamma, Erich, et al. “Design Patterns: Elements of Reusable Object-Oriented Software.” Addison-Wesley, 1994.
  • Oracle. “Java Design Patterns.” docs.oracle.com/javase/tutorial
Scroll to Top