Core Java · Design Patterns · Section 1 of 10

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.

Intermediate
~10 min read
10 sections
Analogy

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.

📚 Coming from the Advanced OOP course?

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:

Creational
How objects are created
Decouple object creation from usage. Examples: Singleton, Factory, Builder, Prototype, Abstract Factory.
Structural
How objects are composed
Combine classes and objects into larger structures. Examples: Decorator, Adapter, Facade, Composite, Proxy.
Behavioural
How objects communicate
Define how objects interact and distribute responsibility. Examples: Observer, Strategy, Template Method, Command, Iterator.

Why Learn Them?

1
Shared vocabulary
Say "use the Builder pattern" in a code review and every Java developer knows exactly what you mean. Patterns are communication tools.
2
Interview currency
Singleton, Factory, Builder, Observer, and Strategy appear in virtually every Java interview at mid-level and above.
3
You already use them
Java's own APIs are built on patterns: Iterator, Collections.sort (Strategy), InputStream decorators, Observable in Swing. Knowing the pattern unlocks the API.
4
Better design instincts
Patterns encode decades of hard-won OOP wisdom. Studying them trains you to spot better solutions faster.
⚠ Don't Over-Pattern
Patterns are solutions to problems — not decorations. Applying a Singleton when you don't need one, or building a Factory for a class with no variants, adds complexity without benefit. The best engineers reach for patterns when the problem fits, not to show off.

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.

Section 1 of 10
Core Java · Design Patterns · Section 2 of 10
Singleton · Creational

Singleton

Ensure a class has exactly one instance and provide a global point of access to it.

Intent
"There should be exactly one instance of this class in the entire JVM, created on demand, accessible from anywhere."

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.

ServiceA.getInstance()
Config — single instance
ServiceB.getInstance()
All callers receive the same object

The Classic Implementation

Config.java⚠ Not Thread-Safe
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

Config.java✓ Thread-Safe
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)

Config.java✓ Enum Singleton — Simplest & Safest
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");
🔑 Why Enum Singleton?
The enum approach is thread-safe by the JVM spec, serialization-safe (won't create a second instance on deserialization), and reflection-safe. Josh Bloch (author of Effective Java) calls it the best Singleton implementation. The only downside is that it can't extend a class.
🧠 Quick Check
What makes the classic lazy Singleton not thread-safe?
Section 2 of 10
Core Java · Design Patterns · Section 3 of 10
Factory Method · Creational

Factory Method

Define an interface for creating an object, but let subclasses — or a factory method — decide which class to instantiate.

Intent
"Instead of calling 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

Before.java⚠ Tightly Coupled Creation
// 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

ShapeFactory.java✓ Factory Pattern
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

PaymentFactory.javaPractical Example
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);
✓ The Key Benefit
The client code works against the interface (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.
Section 3 of 10
Core Java · Design Patterns · Section 4 of 10
Builder · Creational

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.

Intent
"Build an object piece by piece using a fluent chain of calls instead of a constructor with many parameters."

The Problem: Telescoping Constructor

TelescopingConstructor.java⚠ Unreadable
// Which argument is which? Which are optional?
User user = new User("Alice", "alice@example.com", 30, "London", true, false, 5);

Builder Solution

User.java✓ Builder Pattern
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
✓ You Already Know This
Java's 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.
💡 Lombok @Builder
In practice, most Java projects use Lombok's @Builder annotation which generates all this boilerplate automatically. Understanding the pattern manually first is essential — Lombok just eliminates the typing.
Section 4 of 10
Core Java · Design Patterns · Section 5 of 10
Decorator · Structural

Decorator

Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.

Intent
"Wrap an object in another object that adds behaviour before or after delegating to the original — without changing the original class or the interface."
Analogy

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:

JavaIO.javaDecorator in the JDK
// 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

Coffee.java✓ Decorator Pattern
// 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
Section 5 of 10
Core Java · Design Patterns · Section 6 of 10
Strategy · Behavioural

Strategy

Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.

Intent
"Extract an algorithm into its own class behind an interface. Swap algorithms at runtime without changing the class that uses them."

The Problem: Behaviour Hard-Coded with if/else

SortService.java⚠ Hard-Coded Algorithm
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

SortStrategy.java✓ 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
✓ Strategy Is Everywhere
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.
🧠 Quick Check
A Comparator passed to Collections.sort() is an example of which pattern?
Section 6 of 10
Core Java · Design Patterns · Section 7 of 10
Observer · Behavioural

Observer

Define a one-to-many dependency so that when one object changes state, all its dependents are notified and updated automatically.

Intent
"An object (the subject) maintains a list of dependents (observers) and notifies them automatically of state changes — without knowing who they are."
Analogy

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.java✓ Observer Pattern
// 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
📐 Observer in Modern Java
Java's event listeners (Swing, JavaFX), Spring's 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.
Section 7 of 10
Core Java · Design Patterns · Section 8 of 10
Template Method · Behavioural

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.

Intent
"The base class owns the algorithm's structure. Subclasses fill in the specific steps."
DataProcessor.java✓ Template Method
// 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"}
🔑 Template Method in the JDK
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.
Section 8 of 10
Core Java · Design Patterns · Section 9 of 10

Patterns in the Wild

Every pattern you've learned is already all over the Java ecosystem. Here's where to spot them.

PatternIn the JDKIn 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

Use Singleton
One shared resource — config, connection pool, logger. Careful: overused Singletons make testing hard.
Use Factory
You need to create objects but the exact type depends on runtime data or config. Centralises creation logic.
Use Builder
A class has 4+ constructor params, especially optional ones. Makes construction readable and safe.
Use Decorator
You need to add behaviour to objects without modifying the original class. Stack behaviours at runtime.
Use Strategy
You have a family of algorithms and need to swap them. Replaces if/else chains over algorithm choice.
Use Observer
One event should trigger multiple independent reactions. Decouples the event source from its handlers.
Section 9 of 10
Core Java · Design Patterns · Section 10 of 10

Quiz & Coding Challenge

Final Quiz

Question 1 of 4
You need to build an HTTP request object with a URL (required), optional headers, optional timeout, and optional body. Which pattern fits best?
Question 2 of 4
A notification system sends emails, SMS, and push notifications whenever an order ships. Which pattern describes this?
Question 3 of 4
Which pattern does Collections.unmodifiableList(list) implement?
Question 4 of 4
Your app supports multiple discount algorithms: percentage off, buy-one-get-one, fixed amount off. New discount types may be added. Which pattern is ideal?

Coding Challenge

🏋🏋 Challenge — Put It All Together
Order Processing System
Build a mini order system using at least three of the patterns from this lesson.
01Create an Order class using the Builder pattern — fields: customerId, items (List), discountCode (optional), expedited (optional, default false)
02Create a Strategy interface DiscountStrategy with double apply(double total). Implement: NoDiscount, PercentageOff (takes a %), FixedOff (takes a fixed amount)
03Create an OrderProcessor that holds a DiscountStrategy and an Observer list — observers get notified with the final price when an order is processed
04Register two observers: one that logs the order, one that sends a (simulated) email
05Process two orders with different discount strategies and verify both observers fire
OrderSystem.javaSolution
import 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
✓ Phase 2 Complete — What You've Learned Across All Six Lessons
Generics — type-safe reuse · Lambdas & Streams — functional processing · Enums & Records — cleaner data types · Optional — null-safe code · Concurrency — threads and ExecutorService · Design Patterns — Singleton, Factory, Builder, Decorator, Strategy, Observer, Template Method.

You now have the full Phase 2 toolkit. Next up: Phase 3 — Build Things.
Section 10 of 10
Roadmap