Skip to main content
    Courses/Java/OOP Design Patterns

    Lesson 16 • Advanced

    OOP Design Patterns

    Reach for proven blueprints instead of reinventing the wheel. You'll learn the seven Gang of Four patterns you actually meet in Java — and, just as importantly, when to leave them out.

    📚 Before You Start

    You should be comfortable with:

    • OOP & Inheritance — classes, polymorphism, extending
    • Interfaces — contracts and implementations
    • Lambdas & method references from the Advanced Methods lesson

    What You'll Learn in This Lesson

    • Write a safe Singleton two ways — enum and lazy double-checked locking
    • Hide construction behind a Factory Method
    • Assemble complex objects fluently with a Builder
    • Swap algorithms at runtime with Strategy and react to events with Observer
    • Wrap objects to add behaviour (Decorator) or fit an interface (Adapter)
    • Decide when a pattern helps — and when NOT to use one

    🌍 A Real-World Analogy

    Design patterns are like standard blueprints a builder reuses. Nobody redesigns a staircase from scratch on every house — there's a known-good shape for it. Patterns are the same: named, battle-tested solutions to problems that keep coming back, so you don't reinvent (or re-break) them.

    Each pattern is a tiny story you can picture:

    Singleton = the one CEO. Factory = an order desk that hands you the right product. Builder = ordering a custom sandwich, one topping at a time.

    Decorator = gift-wrapping — extra layers, same gift. Adapter = a travel plug that makes a UK charger fit a US socket.

    Strategy = choosing a route on a sat-nav (fastest vs shortest). Observer = a newsletter — subscribe once, get every update automatically.

    Patterns fall into three families: Creational (how objects are made — Singleton, Factory, Builder), Structural (how objects are composed — Decorator, Adapter), and Behavioral (how objects talk to each other — Strategy, Observer).

    1️⃣ Creational Patterns — Controlling Object Creation

    Creational patterns answer one question: how do you make an object? Sometimes you want exactly one of something, sometimes you want to hide which subclass gets built, and sometimes the object is too fiddly for a plain constructor.

    Singleton — exactly one instance.

    A Singleton guarantees a class has a single shared instance — handy for a configuration object, a connection pool, or a logger. In Java the cleanest version is an enum with a single value; the JVM hands you a thread-safe, one-and-only instance with no extra code:

    enum Config {
        INSTANCE;                  // the only instance, created safely by the JVM
        String env() { return "production"; }
    }
    // Usage:
    Config.INSTANCE.env();

    When creation is expensive and you want to defer it, use lazy initialisation with double-checked locking and a volatile field so concurrent threads don't each build their own copy.

    Factory Method — ask for a product by name.

    A Factory centralises the new keyword. Instead of scattering new Circle(...) and new Square(...) around your code, callers ask the factory for a Shape and get the right subtype back — so adding a new shape is a one-line change in one place.

    Worked example: Singleton (enum + lazy) and Factory Method
    public class Main {
    
        // ===== SINGLETON (enum idiom) =====================================
        // The enum idiom is the SAFEST singleton in Java: the JVM guarantees
        // exactly one INSTANCE, and it is thread-safe and serialization-safe
        // for free — no locking code to get wrong.
        enum Config {
            INSTANCE;                              // the one and only instance
            private String env = "production";
            String env() { return env; }
        }
    
        // ===== SINGLETON (lazy, double-checked locking) ===================
        // Use this style when creation is expensive and you want to defer it
        // until first use. 'volatile' + the double null-check make it safe
        // when several threads call getInstance() at the same time.
        static final class Logger {
            private static volatile Logger instance;   // volatile: see writes across threads
            private int lines = 0;
            private Logger() {}                          // private: no 'new Logger()' outside
            static Logger getInstance() {
                if (instance == null) {                  // 1st check (no lock, fast path)
                    synchronized (Logger.class) {
                        if (instance == null) {          // 2nd check (inside the lock)
                            instance = new Logger();
                        }
                    }
                }
                return instance;
            }
            void log(String msg) { System.out.println("  [LOG #" + (++lines) + "] " + msg); }
        }
    
        // ===== FACTORY METHOD ============================================
        // A factory hides 'which class to new up' behind one call. Callers
        // ask for a Shape by name and get the right subtype — they never
        // touch the concrete constructors.
        interface Shape { double area(); }
        record Circle(double r) implements Shape { public double area() { return Math.PI * r * r; } }
        record Square(double s) implements Shape { public double area() { return s * s; } }
    
        static Shape shapeFor(String kind, double size) {
            return switch (kind) {
                case "circle" -> new Circle(size);
                case "square" -> new Square(size);
                default -> throw new IllegalArgumentException("Unknown shape: " + kind);
            };
        }
    
        public static void main(String[] args) {
            System.out.println("=== Singleton (enum) ===");
            Config a = Config.INSTANCE;
            Config b = Config.INSTANCE;
            System.out.println("  env = " + a.env());
            System.out.println("  Same instance? " + (a == b));   // always true
    
            System.out.println("\n=== Singleton (lazy) ===");
            Logger.getInstance().log("Server started");
            Logger.getInstance().log("Request handled");          // same Logger, count keeps going
            System.out.println("  Same instance? " + (Logger.getInstance() == Logger.getInstance()));
    
            System.out.println("\n=== Factory Method ===");
            for (String kind : new String[]{"circle", "square"}) {
                Shape s = shapeFor(kind, 2.0);                     // caller never says 'new Circle'
                System.out.printf("  %-7s area = %.2f%n", kind, s.area());
            }
        }
    }
    Output
    === Singleton (enum) ===
      env = production
      Same instance? true
    
    === Singleton (lazy) ===
      [LOG #1] Server started
      [LOG #2] Request handled
      Same instance? true
    
    === Factory Method ===
      circle  area = 12.57
      square  area = 4.00
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    2️⃣ The Builder Pattern — Assemble Step by Step

    Imagine a constructor with eight parameters, half of them optional and three of them boolean. Calling new Burger("brioche", true, false, true, ...) is unreadable, and one wrong argument order is a silent bug. The Builder pattern fixes this: you set each part by name, chaining calls, then ask build() for the finished object.

    Burger b = new Burger.Builder()
            .bun("brioche")
            .addTopping("lettuce")
            .cheese(true)
            .build();         // validates, then returns an immutable Burger

    Each setter returns this, which is what lets the calls chain. The real power move is putting your validation inside build() — if something's wrong, the object never gets created, so an invalid Burger can't exist anywhere in your program.

    When to use: objects with many optional fields, configuration objects, SQL query construction, or any time a constructor would need more than three or four arguments.

    Worked example: a fluent, validating Builder
    import java.util.ArrayList;
    import java.util.List;
    
    public class Main {
        // ===== BUILDER ===================================================
        // A Builder constructs a complex object step by step. Each setter
        // returns 'this' so the calls chain fluently, and build() validates
        // and produces the finished (immutable) object. This beats a
        // constructor with 8 confusing parameters in a fixed order.
        static final class Burger {
            // 'final' fields: a Burger can't be changed once built (immutable)
            final String bun;
            final List<String> toppings;
            final boolean cheese;
    
            private Burger(Builder b) {       // only the Builder can construct one
                this.bun = b.bun;
                this.toppings = b.toppings;
                this.cheese = b.cheese;
            }
    
            static class Builder {
                private String bun = "plain";          // sensible defaults
                private final List<String> toppings = new ArrayList<>();
                private boolean cheese = false;
    
                Builder bun(String b) { this.bun = b; return this; }
                Builder addTopping(String t) { this.toppings.add(t); return this; }
                Builder cheese(boolean c) { this.cheese = c; return this; }
    
                Burger build() {
                    // Validate HERE so an invalid Burger can never exist
                    if (bun.isBlank()) throw new IllegalStateException("Burger needs a bun");
                    return new Burger(this);
                }
            }
    
            @Override public String toString() {
                return bun + " bun, toppings=" + toppings + (cheese ? ", +cheese" : "");
            }
        }
    
        public static void main(String[] args) {
            System.out.println("=== Builder ===");
    
            Burger plain = new Burger.Builder().build();
            Burger custom = new Burger.Builder()
                    .bun("brioche")
                    .addTopping("lettuce")
                    .addTopping("tomato")
                    .cheese(true)
                    .build();
    
            System.out.println("  1) " + plain);
            System.out.println("  2) " + custom);
    
            try {
                new Burger.Builder().bun("").build();   // empty bun -> build() rejects it
            } catch (IllegalStateException e) {
                System.out.println("  rejected: " + e.getMessage());
            }
        }
    }
    Output
    === Builder ===
      1) plain bun, toppings=[]
      2) brioche bun, toppings=[lettuce, tomato], +cheese
      rejected: Burger needs a bun
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 Your Turn #1 — Finish the Factory

    This factory is almost done. Fill in the three blanks so it returns the right Notifier for each channel. The expected output is written in the comments — run it and check.

    Your Turn: complete the factory
    public class Main {
        interface Notifier { String send(String to); }
        record Email() implements Notifier { public String send(String to) { return "Email -> " + to; } }
        record Sms()   implements Notifier { public String send(String to) { return "SMS -> " + to; } }
    
        // 🎯 YOUR TURN — finish the factory (fill in the blanks marked ___)
    
        static Notifier notifierFor(String channel) {
            return switch (channel) {
                // 👉 1) return a new Email() when channel is "email"
                case "email" -> ___;
                // 👉 2) return a new Sms() when channel is "sms"
                case ___ -> new Sms();
                // 👉 3) throw for anything else (keep the message)
                default -> throw new IllegalArgumentException("Unknown channel: " + channel);
            };
        }
    
        public static void main(String[] args) {
            System.out.println(notifierFor("email").send("ada@dev.io"));
            System.out.println(notifierFor("sms").send("0700-123"));
        }
    
        // ✅ Expected output:
        // Email -> ada@dev.io
        // SMS -> 0700-123
    }
    Output
    Email -> ada@dev.io
    SMS -> 0700-123
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    3️⃣ Structural Patterns — Composing Objects

    Structural patterns are about how objects fit together. Two you'll use constantly are Decorator and Adapter. They look similar — both wrap another object — but they exist for opposite reasons.

    Decorator — add behaviour, keep the interface.

    A Decorator wraps an object to extend it without touching the original class. A milk-wrapped coffee is still a Coffee, so you can stack wrappers as deep as you like: Whip(Sugar(Milk(coffee))). This is exactly how Java's BufferedReader(new FileReader(...)) works.

    Adapter — change the interface to fit.

    An Adapter makes an incompatible class usable. Say a third-party library gives you an XmlReport with a method called toXmlString(), but your code expects the Report interface with render(). You can't edit the library — so you write a small adapter that implements Report and forwards render() to toXmlString().

    The key distinction: a Decorator keeps the same interface and adds to it; an Adapter translates one interface into another. Decorator enhances; Adapter bridges.

    Worked example: Decorator (stack behaviour) and Adapter (bridge interfaces)
    public class Main {
        // ===== DECORATOR =================================================
        // A decorator WRAPS an object to add behaviour without changing the
        // original class. Each wrapper is itself a Coffee, so you can stack
        // them: Whip(Sugar(Milk(BasicCoffee))).
        interface Coffee { double cost(); String describe(); }
        static class BasicCoffee implements Coffee {
            public double cost() { return 2.00; }
            public String describe() { return "Coffee"; }
        }
        static abstract class Add implements Coffee {
            protected final Coffee inner;          // the wrapped coffee
            Add(Coffee c) { this.inner = c; }
        }
        static class Milk extends Add {
            Milk(Coffee c) { super(c); }
            public double cost() { return inner.cost() + 0.50; }
            public String describe() { return inner.describe() + " + Milk"; }
        }
        static class Sugar extends Add {
            Sugar(Coffee c) { super(c); }
            public double cost() { return inner.cost() + 0.25; }
            public String describe() { return inner.describe() + " + Sugar"; }
        }
    
        // ===== ADAPTER ==================================================
        // An adapter makes an INCOMPATIBLE class fit an interface your code
        // expects. Here a third-party 'XmlReport' has a weird method name;
        // the adapter exposes it through the Report interface our app uses.
        interface Report { String render(); }
        static class XmlReport {                    // legacy / 3rd-party — can't change it
            String toXmlString() { return "<report>sales: 42</report>"; }
        }
        static class XmlReportAdapter implements Report {
            private final XmlReport legacy;
            XmlReportAdapter(XmlReport legacy) { this.legacy = legacy; }
            public String render() { return legacy.toXmlString(); }   // translate the call
        }
    
        public static void main(String[] args) {
            System.out.println("=== Decorator ===");
            Coffee order = new Sugar(new Milk(new BasicCoffee()));
            System.out.printf("  %s = $%.2f%n", order.describe(), order.cost());
    
            System.out.println("\n=== Adapter ===");
            Report report = new XmlReportAdapter(new XmlReport());   // legacy now fits Report
            System.out.println("  " + report.render());
        }
    }
    Output
    === Decorator ===
      Coffee + Milk + Sugar = $2.75
    
    === Adapter ===
      <report>sales: 42</report>
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    4️⃣ Behavioral Patterns — How Objects Communicate

    Behavioral patterns describe how objects collaborate and pass responsibility around. The two workhorses are Strategy and Observer.

    Strategy — swap the algorithm at runtime.

    A Strategy lets you choose how something is done, on the fly. A checkout doesn't care whether you pay by card or PayPal — it just calls pay(amount) on whichever strategy you hand it. Adding a new payment method means writing a new strategy, not editing a growing if/else. In modern Java a strategy is usually just a functional interface plus a lambda.

    Observer — broadcast to subscribers.

    An Observer (a.k.a. publish/subscribe) lets many listeners react when something happens, while the publisher knows nothing about them. Subscribe once, and every future publish() notifies you automatically. It's the heart of UI events, messaging systems, and notifications.

    Worked example: Strategy (swap algorithms) and Observer (broadcast events)
    import java.util.ArrayList;
    import java.util.List;
    import java.util.function.Consumer;
    
    public class Main {
        // ===== STRATEGY =================================================
        // Strategy lets you swap an algorithm at runtime. The checkout takes
        // a payment 'strategy' (a function), so adding a new payment method
        // means adding a strategy — not editing the checkout's if/else.
        interface PayStrategy { String pay(double amount); }
    
        static String checkout(double amount, PayStrategy how) {
            return how.pay(amount);
        }
    
        // ===== OBSERVER ================================================
        // Observer lets many subscribers react when something happens, with
        // the publisher knowing nothing about them. Subscribe once; get
        // notified automatically on every emit().
        static class Channel<T> {
            private final List<Consumer<T>> subs = new ArrayList<>();
            Channel<T> subscribe(Consumer<T> s) { subs.add(s); return this; }
            void publish(T event) { for (Consumer<T> s : subs) s.accept(event); }
        }
    
        public static void main(String[] args) {
            System.out.println("=== Strategy ===");
            PayStrategy card = amt -> String.format("Paid $%.2f by card", amt);
            PayStrategy paypal = amt -> String.format("Paid $%.2f via PayPal", amt);
            System.out.println("  " + checkout(19.99, card));
            System.out.println("  " + checkout(19.99, paypal));   // swap algorithm, same checkout
    
            System.out.println("\n=== Observer ===");
            Channel<String> news = new Channel<>();
            news.subscribe(msg -> System.out.println("  Email: " + msg))
                .subscribe(msg -> System.out.println("  SMS:   " + msg));
            news.publish("Sale starts now!");                     // both subscribers react
        }
    }
    Output
    === Strategy ===
      Paid $19.99 by card
      Paid $19.99 via PayPal
    
    === Observer ===
      Email: Sale starts now!
      SMS:   Sale starts now!
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 Your Turn #2 — Make the Builder Chain

    A builder only chains if each setter returns this. Fill in the blanks so the calls chain and the pizza prints correctly. Check your result against the expected output in the comments.

    Your Turn: complete the builder
    public class Main {
        static final class Pizza {
            final String size;
            final boolean extraCheese;
            private Pizza(Builder b) { this.size = b.size; this.extraCheese = b.extraCheese; }
    
            static class Builder {
                private String size = "medium";
                private boolean extraCheese = false;
    
                // 👉 1) make size(...) store the value AND return 'this' so calls chain
                Builder size(String s) { this.size = s; return ___; }
    
                // 👉 2) do the same for extraCheese(...)
                Builder extraCheese(boolean c) { this.extraCheese = c; return this; }
    
                Pizza build() { return new Pizza(this); }
            }
            @Override public String toString() {
                return size + (extraCheese ? " pizza, extra cheese" : " pizza");
            }
        }
    
        public static void main(String[] args) {
            // 🎯 YOUR TURN — chain the builder calls
    
            // 👉 3) build a "large" pizza with extraCheese = true
            Pizza p = new Pizza.Builder().size("large").extraCheese(___).build();
            System.out.println(p);
        }
    
        // ✅ Expected output:
        // large pizza, extra cheese
    }
    Output
    large pizza, extra cheese
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    5️⃣ When NOT to Use a Pattern

    The most senior thing you can learn here is restraint. Every pattern trades extra indirection for flexibility. If you're not getting the flexibility, you're just paying the cost — more files, more layers, harder-to-follow code.

    • 🚫 Don't add a Factory when there's only one concrete class. new User() is fine — wrap it in a factory only when callers genuinely shouldn't know the subtype.
    • 🚫 Don't add a Builder for a two-field object. new Point(x, y) is clearer than new Point.Builder().x(1).y(2).build().
    • 🚫 Don't reach for a Singleton just to get a global variable. Shared mutable state makes testing and threading harder; pass dependencies in instead.
    • 🚫 Don't pattern-match a problem you don't have yet. Add the pattern when a real, repeated need shows up — not in anticipation.

    A good rule: write the simple version first. When you feel the same pain twice — a constructor that keeps growing, an if/else that keeps sprouting cases — that's the signal to refactor toward a pattern.

    🧩 Mini-Challenge — Build Your Own Decorators

    Time to fly solo. Build a tiny Decorator chain from scratch — only a comment outline is given. Follow the steps and match the expected output.

    Mini-Challenge: text decorators
    public class Main {
        // 🎯 MINI-CHALLENGE: Text decorators
        //
        // 1. Make an interface Text with one method: String value()
        // 2. Make a Plain class that returns "hello"
        // 3. Make a decorator Loud that wraps a Text and returns its value
        //    in UPPER CASE  (hint: inner.value().toUpperCase())
        // 4. Make a decorator Exclaim that wraps a Text and adds "!" on the end
        // 5. In main, build:  new Exclaim(new Loud(new Plain()))
        //    and print value()
        //
        // ✅ Expected output:
        // HELLO!
    
        public static void main(String[] args) {
            // your code here
        }
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    Common Errors (and the Fix)

    • Overusing patterns ("patternitis"): wrapping a one-class problem in a Factory, or a two-field object in a Builder, adds layers with no payoff. Fix: start simple; introduce a pattern only when a real, repeated problem appears.
    • Broken Singleton thread-safety: a naive lazy if (instance == null) instance = new X(); lets two threads each create an instance. Fix: use the enum singleton, or double-checked locking with a volatile field as shown above.
    • God objects: one Singleton or one Observer channel that does everything becomes an untestable tangle. Fix: give each responsibility its own type and use domain-specific channels, not a single global hub.
    • Builder that forgets return this; → the calls won't chain and you'll get a compile error like cannot find symbol method bun(String) on the next call. Fix: every setter must end with return this;.
    • Builder with no validation: if build() doesn't check its fields, you can create an invalid object — defeating the point. Fix: validate inside build() and throw if something's wrong.

    Pro Tips

    💡 Enum Singleton wins in Java — thread-safe, serialization-safe and reflection-proof with zero boilerplate.

    💡 Builder + immutable object is a power combo: make every field final and settable only through the builder.

    💡 Lambdas are strategies. A functional interface plus a lambda is the modern, boilerplate-free Strategy pattern — no separate class per algorithm.

    💡 In interviews, knowing when not to use a pattern impresses more than reciting all 23.

    📋 Quick Reference — Pattern → Use

    PatternFamilyPurposeUse it when…
    SingletonCreationalOne shared instanceConfig, logger, connection pool
    Factory MethodCreationalCreate without exposing the classSeveral subtypes chosen at runtime
    BuilderCreationalStep-by-step constructionMany optional fields; immutable objects
    DecoratorStructuralAdd behaviour, same interfaceStackable extras (I/O streams, middleware)
    AdapterStructuralMake incompatible APIs fitWrapping a legacy / third-party class
    StrategyBehavioralSwap algorithm at runtimeInterchangeable behaviours (payment, sort)
    ObserverBehavioralNotify many subscribersEvents, messaging, notifications

    ❓ Frequently Asked Questions

    Do I have to memorise all 23 Gang of Four patterns?

    No. Learn the handful you actually meet — Singleton, Factory Method, Builder, Strategy, Observer, Decorator and Adapter cover the vast majority of real code. Understand the problem each one solves, and you can recognise the rest from a reference when you need them.

    Which Singleton should I use in Java?

    Prefer the enum singleton (enum X { INSTANCE; }). The JVM makes it thread-safe, serialization-safe and reflection-proof for free. Only reach for lazy double-checked locking with a volatile field when construction is genuinely expensive and you must defer it until first use.

    What is the difference between Decorator and Adapter?

    Both wrap another object, but for different reasons. A Decorator adds behaviour while keeping the same interface (coffee + milk is still a coffee). An Adapter changes the interface so an otherwise incompatible class fits the one your code expects — it translates, it does not enhance.

    Strategy uses a lambda — is it still a real pattern?

    Yes. Strategy means 'select an interchangeable algorithm at runtime'. In modern Java a functional interface plus a lambda is the idiomatic way to express it; you do not need a separate class per strategy. The pattern is about the intent, not the boilerplate.

    When should I NOT use a design pattern?

    When it adds structure you do not need yet. A two-line config object does not need a Builder; a class created once does not need a Factory. Patterns trade extra indirection for flexibility — if you are not getting the flexibility, you are just paying the cost. Reach for one when a real, repeated problem appears.

    🎉 Lesson Complete!

    You can now reach for the right blueprint by name: Singleton and Factory and Builder to create objects, Decorator and Adapter to compose them, and Strategy and Observer to wire up behaviour — plus the judgement to leave a pattern out when it doesn't earn its keep.

    Next up: Interfaces, Abstraction & Best Practices — default methods, sealed interfaces, and interface-first design that makes these patterns even cleaner.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service