Skip to content
Java projects 8 min read

Design Patterns

Design patterns are battle-tested, reusable blueprints for solving problems that come up again and again in software design. They are not copy-paste code — they are ideas you adapt to your situation, giving you (and your team) a shared vocabulary for talking about solutions.

Why Design Patterns Matter

When a senior engineer says “use a Factory here” or “that smells like it needs an Observer,” they are referencing patterns documented by the Gang of Four (GoF) in their landmark 1994 book. Learning patterns helps you:

  • Write code that is easier to extend without breaking existing behavior (Open/Closed Principle).
  • Avoid reinventing the wheel for classic structural problems.
  • Communicate intent clearly in code reviews and design discussions.

Patterns are grouped into three families:

FamilyIntentExamples
CreationalControl how objects are createdSingleton, Factory Method, Builder
StructuralCompose classes/objects into larger structuresAdapter, Decorator, Facade
BehavioralDefine how objects communicate and delegateStrategy, Observer, Command

Creational Patterns

Singleton

Ensures only one instance of a class exists and provides a global access point to it. Classic use cases: configuration holders, thread pools, loggers.

public class AppConfig {
    private static volatile AppConfig instance;
    private String environment;

    private AppConfig() {
        this.environment = System.getProperty("env", "production");
    }

    public static AppConfig getInstance() {
        if (instance == null) {
            synchronized (AppConfig.class) {
                if (instance == null) {          // double-checked locking
                    instance = new AppConfig();
                }
            }
        }
        return instance;
    }

    public String getEnvironment() { return environment; }
}
System.out.println(AppConfig.getInstance().getEnvironment()); // production

Note: The volatile keyword prevents the JVM from returning a partially-constructed object due to instruction reordering. See the volatile Keyword page for details.

Tip: An enum-based Singleton (enum Singleton { INSTANCE; }) is the simplest, thread-safe form and handles serialization correctly for free.


Factory Method

Defines an interface for creating an object but lets subclasses (or a factory class) decide which concrete class to instantiate. This decouples the caller from the implementation.

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

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

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

// Factory
class NotificationFactory {
    public static Notification create(String type) {
        return switch (type) {
            case "email" -> new EmailNotification();
            case "sms"   -> new SmsNotification();
            default -> throw new IllegalArgumentException("Unknown type: " + type);
        };
    }
}
Notification n = NotificationFactory.create("sms");
n.send("Your OTP is 4821");

Output:

SMS: Your OTP is 4821

The switch expressions syntax used above (Java 14+) makes the factory clean and exhaustive.


Builder

Useful when constructing a complex object step-by-step, especially when it has many optional fields. Avoids telescoping constructors.

public class HttpRequest {
    private final String url;
    private final String method;
    private final String body;
    private final int timeoutMs;

    private HttpRequest(Builder b) {
        this.url = b.url;
        this.method = b.method;
        this.body = b.body;
        this.timeoutMs = b.timeoutMs;
    }

    public static class Builder {
        private final String url;
        private String method = "GET";
        private String body = "";
        private int timeoutMs = 5000;

        public Builder(String url) { this.url = url; }
        public Builder method(String m) { this.method = m; return this; }
        public Builder body(String b)   { this.body = b;   return this; }
        public Builder timeout(int ms)  { this.timeoutMs = ms; return this; }
        public HttpRequest build()      { return new HttpRequest(this); }
    }

    @Override public String toString() {
        return method + " " + url + " (timeout=" + timeoutMs + "ms)";
    }
}
HttpRequest req = new HttpRequest.Builder("https://api.example.com/data")
        .method("POST")
        .body("{\"key\":\"value\"}")
        .timeout(3000)
        .build();
System.out.println(req);

Output:

POST https://api.example.com/data (timeout=3000ms)

Tip: Java 16+ Records handle simple immutable data carriers very well and may replace Builder for straightforward cases.


Structural Patterns

Adapter

Converts the interface of a class into another interface that a client expects. Like a power socket adapter for travel.

// Legacy class with an incompatible interface
class LegacyPrinter {
    public void printLine(String line) {
        System.out.println("[LEGACY] " + line);
    }
}

// Target interface expected by the rest of the system
interface ModernPrinter {
    void print(String text);
}

// Adapter wraps the legacy class
class PrinterAdapter implements ModernPrinter {
    private final LegacyPrinter legacy;
    public PrinterAdapter(LegacyPrinter legacy) { this.legacy = legacy; }

    @Override
    public void print(String text) {
        legacy.printLine(text);   // delegate
    }
}
ModernPrinter printer = new PrinterAdapter(new LegacyPrinter());
printer.print("Hello, Adapter!");

Output:

[LEGACY] Hello, Adapter!

Decorator

Attaches additional responsibilities to an object dynamically, without modifying its class. Java’s own I/O streams are the textbook example — BufferedInputStream wraps FileInputStream to add buffering. See Java I/O for real-world usage.

interface TextProcessor {
    String process(String input);
}

class PlainText implements TextProcessor {
    public String process(String input) { return input; }
}

class UpperCaseDecorator implements TextProcessor {
    private final TextProcessor inner;
    public UpperCaseDecorator(TextProcessor inner) { this.inner = inner; }
    public String process(String input) { return inner.process(input).toUpperCase(); }
}

class TrimDecorator implements TextProcessor {
    private final TextProcessor inner;
    public TrimDecorator(TextProcessor inner) { this.inner = inner; }
    public String process(String input) { return inner.process(input).trim(); }
}
TextProcessor processor =
    new UpperCaseDecorator(new TrimDecorator(new PlainText()));
System.out.println(processor.process("  hello world  "));

Output:

HELLO WORLD

Facade

Provides a simplified, high-level interface to a complex subsystem — hiding the messy details behind a friendly front door.

class VideoDecoder  { void decode(String file)  { System.out.println("Decoding " + file); } }
class AudioMixer    { void mix(String file)      { System.out.println("Mixing audio for " + file); } }
class VideoEncoder  { void encode(String file)   { System.out.println("Encoding " + file); } }

// Facade
class VideoConverter {
    private final VideoDecoder decoder = new VideoDecoder();
    private final AudioMixer   mixer   = new AudioMixer();
    private final VideoEncoder encoder = new VideoEncoder();

    public void convert(String file) {
        decoder.decode(file);
        mixer.mix(file);
        encoder.encode(file);
        System.out.println("Done: " + file);
    }
}
new VideoConverter().convert("movie.avi");

Output:

Decoding movie.avi
Mixing audio for movie.avi
Encoding movie.avi
Done: movie.avi

Behavioral Patterns

Strategy

Defines a family of algorithms, encapsulates each one, and makes them interchangeable. Lets the algorithm vary independently from the clients that use it.

@FunctionalInterface
interface SortStrategy {
    void sort(int[] data);
}

class BubbleSort implements SortStrategy {
    public void sort(int[] data) { /* bubble sort logic */ System.out.println("Bubble sort"); }
}

class QuickSort implements SortStrategy {
    public void sort(int[] data) { /* quick sort logic */ System.out.println("Quick sort"); }
}

class Sorter {
    private SortStrategy strategy;
    public Sorter(SortStrategy strategy) { this.strategy = strategy; }
    public void setStrategy(SortStrategy strategy) { this.strategy = strategy; }
    public void sort(int[] data) { strategy.sort(data); }
}
Sorter sorter = new Sorter(new QuickSort());
sorter.sort(new int[]{5, 3, 1, 4});   // Quick sort

sorter.setStrategy(data -> System.out.println("Lambda sort!"));
sorter.sort(new int[]{5, 3, 1, 4});   // Lambda sort!

Tip: Because SortStrategy is a functional interface, you can pass a lambda expression directly — no extra class needed.


Observer

Defines a one-to-many dependency: when one object (the Subject) changes state, all its registered Observers are notified automatically. The backbone of event-driven systems.

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

interface Observer {
    void update(String event);
}

class EventBus {
    private final List<Observer> observers = new ArrayList<>();

    public void subscribe(Observer o)   { observers.add(o); }
    public void unsubscribe(Observer o) { observers.remove(o); }

    public void publish(String event) {
        for (Observer o : observers) {
            o.update(event);
        }
    }
}
EventBus bus = new EventBus();
bus.subscribe(event -> System.out.println("Logger received: " + event));
bus.subscribe(event -> System.out.println("Audit received: " + event));

bus.publish("USER_LOGGED_IN");

Output:

Logger received: USER_LOGGED_IN
Audit received: USER_LOGGED_IN

Command

Encapsulates a request as an object, letting you parameterize clients with different requests, queue or log operations, and support undo.

interface Command {
    void execute();
    void undo();
}

class Light {
    void turnOn()  { System.out.println("Light ON");  }
    void turnOff() { System.out.println("Light OFF"); }
}

class LightOnCommand implements Command {
    private final Light light;
    LightOnCommand(Light light) { this.light = light; }
    public void execute() { light.turnOn(); }
    public void undo()    { light.turnOff(); }
}
Light light = new Light();
Command cmd = new LightOnCommand(light);
cmd.execute();   // Light ON
cmd.undo();      // Light OFF

Under the Hood

At the JVM level, patterns impose different costs:

  • Singleton with volatile: The volatile keyword inserts a memory barrier (a lock instruction on x86) ensuring the reference write is fully visible to all threads before any thread can read it. See Java Memory Model.
  • Strategy with lambdas: The JVM uses invokedynamic to wire a lambda to a functional interface at runtime. After JIT warm-up, the dispatch can be as cheap as a direct method call — no heap allocation per call in many cases. See JIT Compilation.
  • Decorator chains: Each wrapping object adds one heap allocation and one virtual dispatch per method call (invokevirtual). For hot paths, prefer flattening decorators or using a single configurable object. See vtable & Dynamic Dispatch.
  • Observer lists: Using ArrayList<Observer> is fine for small subscriber counts. For high-throughput concurrent event buses, prefer CopyOnWriteArrayList from Concurrent Collections to avoid ConcurrentModificationException.

Quick-Reference Cheat Sheet

PatternCategoryWhen to Use
SingletonCreationalOne shared instance (config, connection pool)
Factory MethodCreationalDecouple creation from usage
BuilderCreationalComplex object with many optional params
AdapterStructuralIntegrate incompatible interfaces
DecoratorStructuralAdd behavior without subclassing
FacadeStructuralSimplify a complex subsystem
StrategyBehavioralSwap algorithms at runtime
ObserverBehavioralEvent/notification systems
CommandBehavioralEncapsulate requests, support undo

  • Interfaces — most patterns rely heavily on programming to interfaces, not implementations
  • Lambda Expressions — make Strategy and Command patterns concise with single-method functional interfaces
  • Polymorphism — the engine behind behavioral patterns like Strategy and Observer
  • Collections Framework — many structural patterns appear inside JDK collection classes
  • Java Best Practices — complement design patterns with coding standards for clean, maintainable code
  • Clean Code in Java — patterns shine brightest when combined with readable, well-named code
Last updated June 13, 2026
Was this helpful?