What Are Design Patterns?
Design patterns are proven, named solutions to recurring design problems. They're not code you copy — they're vocabulary and blueprints for structuring code well.
A blueprint for a fire escape doesn't tell architects exactly how to build one — it gives them a proven structural idea that they adapt to their building. Design patterns work the same way. The name carries the idea; the implementation adapts to your problem.
This lesson is the full deep dive on all six patterns referenced there, including three different ways to implement a Singleton correctly. If you haven't yet, the Advanced OOP course covers SOLID principles and where these patterns fit into a larger class design.
The Gang of Four
In 1994, four authors — Gamma, Helm, Johnson, Vlissides (the "Gang of Four") — catalogued 23 patterns in Design Patterns: Elements of Reusable Object-Oriented Software. It remains the foundational reference. Patterns are organised into three categories:
Why Learn Them?
Iterator, Collections.sort (Strategy), InputStream decorators, Observable in Swing. Knowing the pattern unlocks the API.This Lesson Covers Six Patterns
We'll cover the six most commonly used and interviewed patterns in depth, with real Java implementations: Singleton, Factory Method, Builder, Decorator, Strategy, and Observer — plus Template Method as a bonus. Each gets a full section with before/after code and a real-world use case.
Singleton
Ensure a class has exactly one instance and provide a global point of access to it.
When to Use It
Use Singleton when a single shared instance makes logical sense and duplicates would be wasteful or incorrect: database connection pools, configuration managers, logging systems, caches.
The Classic Implementation
public class Config { private static Config instance; // starts null private Config() {} // private — no external new Config() public static Config getInstance() { if (instance == null) { // ❌ race condition in multi-thread instance = new Config(); } return instance; } }
Thread-Safe: Double-Checked Locking
public class Config { // volatile ensures visibility across threads private static volatile Config instance; private final Map<String, String> settings = new HashMap<>(); private Config() { settings.put("db.url", "jdbc:postgresql://localhost/app"); settings.put("db.pool", "10"); settings.put("app.debug", "false"); } public static Config getInstance() { if (instance == null) { // first check (no lock) synchronized (Config.class) { if (instance == null) { // second check (with lock) instance = new Config(); } } } return instance; } public String get(String key) { return settings.get(key); } }
Modern Java: Enum Singleton (Best Practice)
public enum Config { INSTANCE; private final Map<String, String> settings = new HashMap<>(); Config() { settings.put("db.url", "jdbc:postgresql://localhost/app"); } public String get(String key) { return settings.get(key); } } // Usage: String url = Config.INSTANCE.get("db.url");
Factory Method
Define an interface for creating an object, but let subclasses — or a factory method — decide which class to instantiate.
new ConcreteClass() everywhere, delegate object creation to a method or factory class. The caller doesn't need to know which concrete type it gets."The Problem: Hardcoded new
// Client decides exactly which class to create Shape s; if (type.equals("circle")) s = new Circle(radius); else if (type.equals("square")) s = new Square(side); else if (type.equals("triangle")) s = new Triangle(base, height); // Adding a new shape means changing THIS code everywhere it appears
Factory Method — Simple Static Factory
public interface Shape { double area(); void draw(); } public class Circle implements Shape { private final double radius; public Circle(double r) { this.radius = r; } public double area() { return Math.PI * radius * radius; } public void draw() { System.out.println("Drawing Circle r=" + radius); } } public class Rectangle implements Shape { private final double w, h; public Rectangle(double w, double h) { this.w=w; this.h=h; } public double area() { return w * h; } public void draw() { System.out.println("Drawing Rectangle " + w + "x" + h); } } // The factory — one place for all creation logic public class ShapeFactory { public static Shape create(String type, double... args) { return switch (type.toLowerCase()) { case "circle" -> new Circle(args[0]); case "rectangle" -> new Rectangle(args[0], args[1]); default -> throw new IllegalArgumentException("Unknown: " + type); }; } } // Client — doesn't know or care which concrete class it gets Shape s1 = ShapeFactory.create("circle", 5.0); Shape s2 = ShapeFactory.create("rectangle", 4.0, 6.0); s1.draw(); // Drawing Circle r=5.0 System.out.printf("Area: %.2f%n", s2.area()); // Area: 24.00
Real-World Factory: Payment Provider
public interface PaymentProcessor { void processPayment(double amount); } public class StripeProcessor implements PaymentProcessor { /* ... */ } public class PayPalProcessor implements PaymentProcessor { /* ... */ } public class CryptoProcessor implements PaymentProcessor { /* ... */ } public class PaymentFactory { public static PaymentProcessor getProcessor(String provider) { return switch (provider) { case "stripe" -> new StripeProcessor(); case "paypal" -> new PayPalProcessor(); case "crypto" -> new CryptoProcessor(); default -> throw new IllegalArgumentException("Unknown provider"); }; } } // From a config value — no concrete class name in business logic PaymentProcessor p = PaymentFactory.getProcessor(config.get("payment.provider")); p.processPayment(99.99);
PaymentProcessor), not the concrete class. Swap providers by changing config — zero client code changes. Adding a new provider means adding one class and one case in the factory.
Builder
Construct complex objects step by step. Separate the construction of an object from its representation so you can produce different representations using the same process.
The Problem: Telescoping Constructor
// Which argument is which? Which are optional? User user = new User("Alice", "alice@example.com", 30, "London", true, false, 5);
Builder Solution
public class User { // All fields final — immutable once built private final String name; private final String email; private final int age; private final String city; private final boolean premium; private final boolean newsletter; // Private constructor — only Builder can create User private User(Builder b) { this.name = b.name; this.email = b.email; this.age = b.age; this.city = b.city; this.premium = b.premium; this.newsletter = b.newsletter; } public static class Builder { // Required fields private final String name; private final String email; // Optional fields — default values private int age = 0; private String city = "Unknown"; private boolean premium = false; private boolean newsletter = false; public Builder(String name, String email) { this.name = name; this.email = email; } // Each setter returns Builder — enables fluent chaining public Builder age(int age) { this.age=age; return this; } public Builder city(String city) { this.city=city; return this; } public Builder premium(boolean p) { this.premium=p; return this; } public Builder newsletter(boolean n) { this.newsletter=n; return this; } public User build() { if (name == null || name.isBlank()) throw new IllegalStateException("Name required"); return new User(this); } } } // Usage — reads like English User alice = new User.Builder("Alice", "alice@example.com") .age(30) .city("London") .premium(true) .build(); User bob = new User.Builder("Bob", "bob@example.com") .build(); // minimum required fields only
StringBuilder is a classic Builder. Spring's MockMvcRequestBuilders, Lombok's @Builder, and practically every HTTP client (HttpRequest.newBuilder()) all use this pattern. If you've chained method calls to build something, you've used Builder.
@Builder annotation which generates all this boilerplate automatically. Understanding the pattern manually first is essential — Lombok just eliminates the typing.
Decorator
Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
A coffee order: you start with espresso, then "decorate" it — add milk (becomes latte), add sugar, add syrup. Each addition wraps the previous, adding its behaviour without changing the underlying coffee. The final object is still a coffee — just with extra responsibilities stacked on top.
Java's Own Decorator: InputStream
Before writing your own, notice that Java's I/O system is entirely built on Decorator:
// Each wraps the previous — pure Decorator pattern InputStream raw = new FileInputStream("data.txt"); InputStream buffered = new BufferedInputStream(raw); // adds buffering DataInputStream typed = new DataInputStream(buffered); // adds typed reads int value = typed.readInt(); // reads int, buffered, from file
Building Your Own Decorator
// Component interface public interface Coffee { String getDescription(); double getCost(); } // Concrete component public class Espresso implements Coffee { public String getDescription() { return "Espresso"; } public double getCost() { return 1.50; } } // Abstract decorator — implements same interface, wraps a Coffee public abstract class CoffeeDecorator implements Coffee { protected final Coffee wrapped; public CoffeeDecorator(Coffee c) { this.wrapped = c; } } // Concrete decorators public class Milk extends CoffeeDecorator { public Milk(Coffee c) { super(c); } public String getDescription() { return wrapped.getDescription() + ", Milk"; } public double getCost() { return wrapped.getCost() + 0.30; } } public class Syrup extends CoffeeDecorator { public Syrup(Coffee c) { super(c); } public String getDescription() { return wrapped.getDescription() + ", Syrup"; } public double getCost() { return wrapped.getCost() + 0.50; } } // Compose at runtime — any combination Coffee order = new Syrup(new Milk(new Espresso())); System.out.println(order.getDescription()); // Espresso, Milk, Syrup System.out.printf("%.2f%n", order.getCost()); // 2.30
Strategy
Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
The Problem: Behaviour Hard-Coded with if/else
public void sort(List<Integer> data, String algo) { if (algo.equals("bubble")) { // 20 lines of bubble sort } else if (algo.equals("quick")) { // 40 lines of quicksort } else if (algo.equals("merge")) { // 50 lines of mergesort } // Adding new algorithm = touching this method = risk of breaking existing }
Strategy Pattern
// Strategy interface public interface SortStrategy { void sort(List<Integer> data); } // Concrete strategies public class BubbleSort implements SortStrategy { public void sort(List<Integer> data) { System.out.println("Bubble sorting..."); // bubble sort logic } } public class QuickSort implements SortStrategy { public void sort(List<Integer> data) { System.out.println("Quick sorting..."); Collections.sort(data); // simplified for demo } } // Context — uses whatever strategy is injected public class Sorter { private SortStrategy strategy; public Sorter(SortStrategy strategy) { this.strategy = strategy; } public void setStrategy(SortStrategy s) { this.strategy = s; } public void sort(List<Integer> data) { strategy.sort(data); // delegates to strategy } } // Usage — swap algorithms at runtime List<Integer> data = new ArrayList<>(List.of(5, 2, 8, 1)); Sorter sorter = new Sorter(new BubbleSort()); sorter.sort(data); // Bubble sorting... sorter.setStrategy(new QuickSort()); sorter.sort(data); // Quick sorting... // With lambdas — strategy as a lambda (Strategy = functional interface) sorter.setStrategy(list -> Collections.reverse(list)); sorter.sort(data); // reverses instead
Comparator IS the Strategy pattern — you pass a different comparison strategy to sort() each time. Collections.sort(list, comparator) is a Strategy call. Every time you pass a lambda to sort(), you're applying the Strategy pattern.
Comparator passed to Collections.sort() is an example of which pattern?Observer
Define a one-to-many dependency so that when one object changes state, all its dependents are notified and updated automatically.
A YouTube channel and its subscribers. When a video is uploaded (event), every subscriber gets notified. The channel doesn't know who its subscribers are — it just calls notify(). Subscribers decide what to do with the notification.
Implementation
// Observer interface — what observers must implement public interface StockObserver { void onPriceChange(String symbol, double newPrice); } // Subject (Observable) — maintains observer list public class StockMarket { private final Map<String, Double> prices = new HashMap<>(); private final List<StockObserver> observers = new ArrayList<>(); public void subscribe(StockObserver o) { observers.add(o); } public void unsubscribe(StockObserver o) { observers.remove(o); } public void setPrice(String symbol, double price) { prices.put(symbol, price); notifyObservers(symbol, price); // broadcast to all } private void notifyObservers(String symbol, double price) { observers.forEach(o -> o.onPriceChange(symbol, price)); } } // Concrete observers public class PriceAlertBot implements StockObserver { private final double threshold; public PriceAlertBot(double t) { this.threshold = t; } public void onPriceChange(String symbol, double price) { if (price > threshold) System.out.println("ALERT: " + symbol + " hit $" + price); } } public class PortfolioTracker implements StockObserver { public void onPriceChange(String symbol, double price) { System.out.printf("Portfolio update: %s = $%.2f%n", symbol, price); } } // Usage StockMarket market = new StockMarket(); market.subscribe(new PriceAlertBot(150.0)); market.subscribe(new PortfolioTracker()); market.subscribe( // lambda observer (sym, p) -> System.out.println("Log: " + sym + "=" + p)); market.setPrice("AAPL", 155.50); // ALERT: AAPL hit $155.5 // Portfolio update: AAPL = $155.50 // Log: AAPL=155.5
ApplicationEvent system, and reactive libraries (RxJava, Project Reactor) are all built on Observer. When you call button.addActionListener(e -> ...) in Swing, you're registering an observer.
Template Method
Define the skeleton of an algorithm in a base class, deferring some steps to subclasses. Let subclasses redefine certain steps without changing the algorithm's structure.
// Abstract class defines the template public abstract class DataProcessor { // Template method — the algorithm skeleton (final = no override) public final void process() { String raw = readData(); // step 1 String proc = processData(raw); // step 2 writeData(proc); // step 3 } // Abstract steps — subclasses must implement protected abstract String readData(); protected abstract String processData(String raw); protected abstract void writeData(String result); } // CSV implementation public class CsvProcessor extends DataProcessor { protected String readData() { return "raw,csv,data"; } protected String processData(String raw) { return raw.replace(","," | "); } protected void writeData(String result) { System.out.println("CSV: " + result); } } // JSON implementation public class JsonProcessor extends DataProcessor { protected String readData() { return "{\"key\":\"value\"}"; } protected String processData(String raw) { return raw.toUpperCase(); } protected void writeData(String result) { System.out.println("JSON: " + result); } } // Usage — same process() call, different behaviour new CsvProcessor().process(); // CSV: raw | csv | data new JsonProcessor().process(); // JSON: {"KEY":"VALUE"}
AbstractList, AbstractMap, AbstractSet — all of Java's abstract collection classes use Template Method. They implement most methods in terms of a few abstract ones (get(), size()) that subclasses provide. Spring's JdbcTemplate, RestTemplate — same idea.
Patterns in the Wild
Every pattern you've learned is already all over the Java ecosystem. Here's where to spot them.
| Pattern | In the JDK | In Frameworks |
|---|---|---|
| Singleton | Runtime.getRuntime(), System.console() |
Spring beans (default scope), Hibernate SessionFactory |
| Factory | Calendar.getInstance(), NumberFormat.getInstance(), List.of() |
Spring BeanFactory, JDBC DriverManager.getConnection() |
| Builder | StringBuilder, HttpRequest.newBuilder(), Stream.Builder |
Lombok @Builder, Spring MockMvcRequestBuilders, Hibernate Criteria API |
| Decorator | BufferedReader(FileReader), Collections.unmodifiableList(), Collections.synchronizedList() |
Spring AOP (method interceptors), Servlet filters |
| Strategy | Comparator, Runnable, Callable, all functional interfaces |
Spring ResourceLoader, Jackson SerializerFactory |
| Observer | java.util.Observer (deprecated but notable), all Swing listeners |
Spring ApplicationEvent, RxJava, Kafka consumer groups |
| Template Method | AbstractList, AbstractMap, HttpServlet.service() |
Spring JdbcTemplate, RestTemplate, AbstractController |
Quick Decision Guide
Quiz & Coding Challenge
Final Quiz
Collections.unmodifiableList(list) implement?Coding Challenge
Order class using the Builder pattern — fields: customerId, items (List), discountCode (optional), expedited (optional, default false)DiscountStrategy with double apply(double total). Implement: NoDiscount, PercentageOff (takes a %), FixedOff (takes a fixed amount)OrderProcessor that holds a DiscountStrategy and an Observer list — observers get notified with the final price when an order is processedimport java.util.*; // BUILDER — Order class Order { final String customerId; final List<String> items; final String discountCode; final boolean expedited; private Order(Builder b) { customerId = b.customerId; items = b.items; discountCode = b.discountCode; expedited = b.expedited; } static class Builder { final String customerId; List<String> items = new ArrayList<>(); String discountCode = null; boolean expedited = false; Builder(String id) { this.customerId = id; } Builder items(List<String> i) { this.items = i; return this; } Builder discount(String code) { this.discountCode = code; return this; } Builder expedited() { this.expedited = true; return this; } Order build() { return new Order(this); } } } // STRATEGY — Discount interface DiscountStrategy { double apply(double total); } class NoDiscount implements DiscountStrategy { public double apply(double t) { return t; } } class PercentageOff implements DiscountStrategy { final double pct; PercentageOff(double p) { pct = p; } public double apply(double t) { return t * (1 - pct / 100); } } class FixedOff implements DiscountStrategy { final double amount; FixedOff(double a) { amount = a; } public double apply(double t) { return Math.max(0, t - amount); } } // OBSERVER — OrderObserver interface OrderObserver { void onOrderProcessed(Order o, double finalPrice); } // OrderProcessor — uses Strategy + Observer class OrderProcessor { private DiscountStrategy strategy; private List<OrderObserver> observers = new ArrayList<>(); OrderProcessor(DiscountStrategy s) { this.strategy = s; } void subscribe(OrderObserver o) { observers.add(o); } void process(Order order, double baseTotal) { double final$ = strategy.apply(baseTotal); observers.forEach(o -> o.onOrderProcessed(order, final$)); } } // Main OrderProcessor proc = new OrderProcessor(new PercentageOff(10)); proc.subscribe((o, p) -> System.out.printf("LOG: Order for %s — $%.2f%n", o.customerId, p)); proc.subscribe((o, p) -> System.out.printf("EMAIL: Hi %s, your order total is $%.2f%n", o.customerId, p)); Order o1 = new Order.Builder("C001") .items(List.of("Keyboard", "Mouse")) .discount("SAVE10").build(); proc.process(o1, 150.00); // LOG: Order for C001 — $135.00 // EMAIL: Hi C001, your order total is $135.00
You now have the full Phase 2 toolkit. Next up: Phase 3 — Build Things.