25:00
Focus
Lesson 7
~18 min125 XP

Effective Java Design Patterns: Builder, Singleton, and Strategy

Introduction

Design patterns are battle-tested solutions to recurring software design problems — they're the vocabulary that professional Java developers use to communicate architectural decisions quickly and precisely. In this lesson, you'll go beyond textbook examples and learn how to implement the Builder, Singleton, and Strategy patterns the way they're actually used in production Java codebases. By the end, you'll understand not just how to write these patterns, but why each design decision matters and what pitfalls to avoid.

Why Design Patterns Matter in Professional Java

Design patterns aren't about memorizing templates — they're about recognizing structural problems and applying proven solutions. When a senior developer says "use a Builder here," they're communicating a rich set of ideas: the object has too many constructor parameters, some are optional, and we need the construction process to be readable and safe.

The three patterns we'll cover solve distinct problems:

  • Builder Pattern: Solves the problem of constructing complex objects with many optional parameters. Without it, you end up with telescoping constructors (overloaded constructors with increasing parameter counts) or JavaBeans-style setters that leave objects in inconsistent states.
  • Singleton Pattern: Ensures a class has exactly one instance and provides a global access point. This is critical for managing shared resources like configuration managers, connection pools, or logging services.
  • Strategy Pattern: Encapsulates interchangeable algorithms behind a common interface, letting you swap behavior at runtime without modifying the classes that use it.

Each of these patterns follows core object-oriented principles — encapsulation, open/closed principle, and programming to interfaces — but they apply those principles in different ways. The professional implementations we'll build differ significantly from the naive versions you'll find in introductory tutorials. We'll handle thread safety, immutability, serialization edge cases, and API ergonomics — the concerns that separate production code from classroom exercises.

A pattern is not just code you copy — it's a design decision you make. Understanding when NOT to use a pattern is just as important as knowing how to implement one.

The Builder Pattern: Constructing Complex Objects Safely

The Builder pattern shines when you have objects with many fields — especially when some are required and others are optional. Consider a UserProfile class with 8+ fields. You could write a constructor with 8 parameters, but callers would constantly confuse the order. You could use setters, but then the object can exist in a half-constructed, invalid state.

The Builder solves both problems: it provides a fluent, readable API for construction while ensuring the final object is immutable and valid.

Here's the professional way to implement it, following the approach Joshua Bloch describes in Effective Java:

Key professional details to notice:

  1. The outer class is final and all fields are final — the resulting object is completely immutable and thread-safe without synchronization.
  2. Required parameters are enforced in the Builder's constructor. You can't even start building without them.
  3. Validation happens in the Builder methods, not after construction. The age() method rejects invalid values immediately.
  4. Each setter method returns this, enabling the fluent interface style: method chaining that reads almost like English.
  5. The outer class constructor is private — objects can only be created through the Builder, closing the door on invalid states.

A common mistake is putting validation only in the build() method. While final cross-field validation belongs there (e.g., "if age < 18, parental email is required"), individual field validation should happen in each setter for immediate feedback.

Exercise 1Multiple Choice
Why is the UserProfile constructor marked private in the Builder pattern?

The Singleton Pattern: Getting It Right (It's Harder Than You Think)

The Singleton pattern looks deceptively simple — just make one instance, right? In practice, naive implementations break under multithreading, serialization, and reflection. Let's look at the approaches from worst to best.

The Naive Approach (Broken):

// DON'T do this in production
public class ConfigManager {
    private static ConfigManager instance;
    
    private ConfigManager() { }
    
    public static ConfigManager getInstance() {
        if (instance == null) {
            instance = new ConfigManager(); // Race condition!
        }
        return instance;
    }
}

This breaks under concurrent access. Two threads can both see instance as null and create two separate instances — violating the entire point of the pattern.

The Synchronized Approach (Correct but Slow): Adding synchronized to getInstance() fixes the race condition but introduces a performance bottleneck — every single call pays the synchronization cost, even after the instance is already created.

The Enum Singleton (The Professional Way):

Joshua Bloch calls this "the best way to implement a singleton." An enum singleton is concise, thread-safe by the JVM specification, and automatically handles serialization and reflection attacks:

If you can't use an enum (e.g., you need to extend a class, since enums implicitly extend java.lang.Enum), the next best approach is the Bill Pugh Singleton using a static inner holder class:

public class ConfigManager {
    private ConfigManager() { }

    private static class Holder {
        private static final ConfigManager INSTANCE = new ConfigManager();
    }

    public static ConfigManager getInstance() {
        return Holder.INSTANCE;
    }
}

This leverages the JVM's class loading mechanism: the Holder class isn't loaded until getInstance() is called, giving you lazy initialization with thread safety and zero synchronization overhead.

When to avoid Singleton: If your "singleton" is really just a bag of utility methods with no state, use a class with private constructor and static methods instead. Singletons manage state — if there's no state, there's no need for the pattern.

Exercise 2Multiple Choice
What makes the enum Singleton superior to the double-checked locking approach?

The Strategy Pattern: Swapping Algorithms at Runtime

The Strategy pattern is one of the most practical patterns in real-world Java. It lets you define a family of algorithms, encapsulate each one, and make them interchangeable. The key insight: instead of hardcoding conditional logic (if/else or switch statements), you extract each behavior into its own class behind a common interface.

Imagine you're building a payment processing system. You need to support credit cards, PayPal, and cryptocurrency. The naive approach is a giant switch statement. Every time you add a payment method, you modify existing code — violating the open/closed principle (open for extension, closed for modification).

The Strategy pattern inverts this: each payment method implements a PaymentStrategy interface, and the processing code works with the interface, never knowing (or caring) which concrete strategy it's using.

With modern Java (8+), the Strategy pattern has evolved. If your strategy interface is a functional interface (one abstract method), you can use lambdas instead of creating separate classes for simple strategies. This gives you the best of both worlds: the design flexibility of Strategy with the conciseness of functional programming.

The three participants in the Strategy pattern are:

  1. Strategy Interface (PricingStrategy): Declares the contract that all strategies must follow.
  2. Concrete Strategies (RegularPricing, BulkDiscountPricing, or lambdas): The actual algorithm implementations.
  3. Context (ShoppingCart): The class that holds a reference to a strategy and delegates work to it.

A common mistake is using Strategy when a simple method parameter would suffice. If you only have one place that uses the "strategy" and never swap it, you've over-engineered. Strategy shines when the same context needs different behaviors at different times, or when multiple contexts share the same set of interchangeable behaviors.

Exercise 3True or False
Marking a Strategy interface with @FunctionalInterface means you can no longer create concrete classes that implement it — only lambdas can be used.

Combining Patterns: Real-World Architecture

In professional codebases, patterns rarely exist in isolation. They compose together to solve larger problems. Let's see how Builder, Singleton, and Strategy can work together in a realistic notification system:

Consider a service that sends notifications through different channels (email, SMS, push). The notification objects are complex (Builder), the service should be a shared resource (Singleton), and the delivery channel should be swappable (Strategy).

// Strategy: Notification delivery
@FunctionalInterface
public interface DeliveryChannel {
    void send(Notification notification);
}

// Builder: Complex notification object
public final class Notification {
    private final String recipient;
    private final String subject;
    private final String body;
    private final Priority priority;
    private final Map<String, String> metadata;

    private Notification(Builder builder) {
        this.recipient = builder.recipient;
        this.subject = builder.subject;
        this.body = builder.body;
        this.priority = builder.priority;
        this.metadata = Collections.unmodifiableMap(
            new HashMap<>(builder.metadata));
    }

    // Builder class omitted for brevity — same pattern as before

    public static class Builder {
        private final String recipient;
        private String subject = "";
        private String body = "";
        private Priority priority = Priority.NORMAL;
        private Map<String, String> metadata = new HashMap<>();

        public Builder(String recipient) {
            this.recipient = Objects.requireNonNull(recipient);
        }
        public Builder subject(String s) { this.subject = s; return this; }
        public Builder body(String b) { this.body = b; return this; }
        public Builder priority(Priority p) { this.priority = p; return this; }
        public Builder metadata(String k, String v) {
            this.metadata.put(k, v); return this;
        }
        public Notification build() { return new Notification(this); }
    }
}

// Singleton: Notification service
public enum NotificationService {
    INSTANCE;

    private DeliveryChannel defaultChannel;

    public void setDefaultChannel(DeliveryChannel channel) {
        this.defaultChannel = channel;
    }

    public void send(Notification notification) {
        send(notification, defaultChannel);
    }

    public void send(Notification notification, DeliveryChannel channel) {
        Objects.requireNonNull(channel, "No delivery channel configured");
        channel.send(notification);
    }
}

Usage brings all three patterns together cleanly:

// Configure the singleton with a strategy
NotificationService.INSTANCE.setDefaultChannel(
    notification -> EmailGateway.send(notification));

// Build and send a notification
Notification alert = new Notification.Builder("user@example.com")
    .subject("Server Alert")
    .body("CPU usage exceeded 90%")
    .priority(Priority.HIGH)
    .metadata("server", "prod-web-03")
    .build();

NotificationService.INSTANCE.send(alert);

// Override channel for a specific notification
NotificationService.INSTANCE.send(alert,
    notification -> SmsGateway.send(notification));

This composition demonstrates a critical professional skill: each pattern handles one concern. The Builder guarantees well-formed notification objects. The Singleton ensures a single service entry point. The Strategy makes the delivery mechanism pluggable. No single pattern is overloaded with responsibilities.

Exercise 4Fill in the Blank
In the combined notification system, the Notification class uses a Builder for construction, the NotificationService uses the ___ pattern to ensure one shared instance, and the DeliveryChannel uses Strategy to swap delivery behavior.

Common Pitfalls and Anti-Patterns

Even experienced developers misapply these patterns. Here are the traps to avoid:

Builder Pitfalls:

  • Forgetting to make the built object immutable: If your class has setters alongside a Builder, you've gained nothing — callers can still mutate the object after construction. Remove all setters from the outer class.
  • Not copying mutable fields: If your Builder accepts a List or Map, the build() method should create a defensive copy. Otherwise, callers can modify the original collection and corrupt the built object. Use Collections.unmodifiableMap(new HashMap<>(builder.metadata)) — the new HashMap<>() creates a copy, and unmodifiableMap prevents modification.
  • Overusing Builder for simple objects: If your class has 2-3 required parameters and no optional ones, a plain constructor is simpler and clearer. Builder adds value when parameter counts grow or optionality makes constructors ambiguous.

Singleton Pitfalls:

  • Global mutable state nightmare: Singletons that carry a lot of mutable state become invisible dependencies that make testing painful. If you need a singleton in tests, consider dependency injection frameworks (Spring, Guice) that manage lifecycle for you.
  • Double-checked locking without volatile: If you must use the classic getInstance() approach, the instance field MUST be declared volatile. Without it, the Java Memory Model allows a thread to see a partially constructed object. This is a subtle bug that may only surface under high concurrency on specific hardware.
  • Using Singleton when you mean static utility: A class with no instance state (just static methods) doesn't need the Singleton pattern at all.

Strategy Pitfalls:

  • Over-engineering with single-use strategies: If there's exactly one algorithm and no foreseeable reason to swap it, adding a Strategy interface is unnecessary abstraction. Apply the pattern when you genuinely have multiple behaviors or expect them.
  • Fat strategy interfaces: If your strategy interface has 5+ methods, it's probably not a single strategy — it might be a full-blown service interface. Good strategies are focused: one method, one job.
  • Ignoring functional interfaces: In modern Java, if your strategy has a single method, annotate it @FunctionalInterface. This allows callers to use lambdas for simple cases and full classes for complex ones — maximum flexibility.

Rule of thumb: Apply a pattern when you feel the pain it solves. Don't preemptively architect for problems that may never arise. The best codebases apply patterns surgically, not religiously.

Key Takeaways

  • The Builder pattern creates readable, safe construction APIs for complex objects — enforce required fields in the Builder constructor, validate in setter methods, and always make the built object immutable.
  • The Singleton pattern is best implemented as an enum in Java, which gives you thread safety, serialization protection, and reflection resistance with zero boilerplate — avoid double-checked locking unless you have a specific reason.
  • The Strategy pattern encapsulates interchangeable algorithms behind a common interface — in modern Java, mark strategy interfaces as @FunctionalInterface to enable lambda usage alongside traditional class implementations.
  • Patterns compose naturally: Builder handles object construction, Singleton manages shared resources, and Strategy provides behavioral flexibility — use each for its specific concern, and don't force patterns where simpler solutions suffice.
Check Your Understanding

In production Java applications, developers often need to decide between using the Builder pattern and using JavaBeans-style construction (creating an object with a no-argument constructor, then calling setter methods to configure it). Consider a scenario where you are designing a class representing an HTTP request configuration with 12 fields, 4 of which are required and 8 of which are optional, in a multithreaded web server environment. Explain why the Builder pattern would be preferred over the JavaBeans approach in this scenario, specifically addressing the concepts of object consistency, immutability, and thread safety. In your explanation, also describe how telescoping constructors attempt to solve the same problem and why they fall short as the number of parameters grows.

🔒Upgrade to submit written responses and get AI feedback
Go deeper
  • When should I choose Builder over telescoping constructors?🔒
  • How does enum Singleton prevent reflection attacks?🔒
  • Can Strategy pattern replace complex if-else chains?🔒
  • What makes JavaBeans setters leave inconsistent states?🔒
  • How do these patterns interact in real codebases?🔒