LLDintermediate

Design Patterns — Creational

Creational patterns — Singleton, Factory Method, Abstract Factory, Builder, and Prototype — solve the fundamental problem of how to create objects flexibly without coupling your code to specific classes. Knowing when each pattern applies and what it costs is what makes the difference between a good LLD answer and a great one.

Reading time

20 min

design patternsOOPcreationalsingletonfactorybuilder

Why Creational Patterns Exist

Every software system creates objects. The naive approach — new ConcreteClass() scattered throughout your code — creates invisible coupling. When the concrete class changes (its constructor, its dependencies, its lifecycle), every callsite must change with it.

Creational patterns decouple what gets created from how it is created and who creates it. They give you a single, controlled point of object creation that can be changed without touching the code that uses the objects.

Pattern 1: Singleton

Intent: Ensure a class has exactly one instance and provide global access to it.

When to use:

  • A shared resource that must be exactly one: database connection pool, logger, configuration manager, thread pool
  • When creating the object is expensive and you want to do it only once
  • When multiple instances would be semantically wrong (two config managers with different configs)

Thread-Safe Singleton (Java)

java
public class DatabaseConnectionPool {
    // volatile ensures visibility across threads (Java memory model)
    private static volatile DatabaseConnectionPool instance;
    private final List<Connection> pool;

    private DatabaseConnectionPool() {
        pool = initializePool(); // Expensive operation — done once
    }

    public static DatabaseConnectionPool getInstance() {
        // Double-checked locking — fast path avoids synchronization after init
        if (instance == null) {
            synchronized (DatabaseConnectionPool.class) {
                if (instance == null) {
                    instance = new DatabaseConnectionPool();
                }
            }
        }
        return instance;
    }

    public Connection getConnection() { /* ... */ }
}

Enum Singleton (Preferred in Java)

java
public enum AppConfig {
    INSTANCE;

    private final Properties props;

    AppConfig() {
        props = loadProperties("config.properties");
    }

    public String get(String key) { return props.getProperty(key); }
    public int getInt(String key) { return Integer.parseInt(get(key)); }
}

// Usage
String dbUrl = AppConfig.INSTANCE.get("db.url");

The enum approach is thread-safe by JVM guarantee, serialisation-safe (double-checked locking can break with serialisation), and immune to reflection attacks.

Pitfalls:

  • Hard to unit test (can't easily inject a mock — use interfaces to wrap the singleton)
  • Becomes a god object if it accumulates too many responsibilities (SRP violation)
  • In distributed systems, each JVM has its own singleton — they are not globally unique across a cluster

Pattern 2: Factory Method

Intent: Define an interface for creating an object, but let subclasses decide which class to instantiate. Defers instantiation to subclasses.

When to use:

  • The exact type of object to create is determined at runtime
  • You want subclasses to customise what gets created
  • You need to encapsulate creation logic that may vary

Example: Notification Factory

java
// The product interface
interface Notification {
    void send(String message, String recipient);
    String getType();
}

// Concrete products
class EmailNotification implements Notification {
    public void send(String message, String recipient) {
        System.out.println("Sending email to " + recipient + ": " + message);
    }
    public String getType() { return "EMAIL"; }
}

class SmsNotification implements Notification {
    public void send(String message, String recipient) {
        System.out.println("Sending SMS to " + recipient + ": " + message);
    }
    public String getType() { return "SMS"; }
}

class PushNotification implements Notification {
    public void send(String message, String recipient) {
        System.out.println("Sending push to device " + recipient + ": " + message);
    }
    public String getType() { return "PUSH"; }
}

// The factory — one creation point for all notification types
class NotificationFactory {
    public static Notification create(String type) {
        return switch (type.toUpperCase()) {
            case "EMAIL" -> new EmailNotification();
            case "SMS"   -> new SmsNotification();
            case "PUSH"  -> new PushNotification();
            default      -> throw new IllegalArgumentException("Unknown type: " + type);
        };
    }
}

// Usage — caller never uses 'new' directly
Notification n = NotificationFactory.create(user.getPreferredChannel());
n.send("Your order has shipped", user.getContact());

OCP compliance: Adding WhatsApp notifications requires a new WhatsAppNotification class and one new case in the factory — no existing code changes.

Pattern 3: Abstract Factory

Intent: Provide an interface for creating families of related or dependent objects without specifying their concrete classes.

Think of it as a "factory of factories." Where Factory Method creates one type of object, Abstract Factory creates a suite of related objects that must be used together.

Example: Cross-Platform UI Components

java
// Abstract products
interface Button { void render(); void onClick(Runnable handler); }
interface Checkbox { void render(); boolean isChecked(); }
interface TextField { void render(); String getValue(); }

// Windows implementations
class WindowsButton implements Button { ... }
class WindowsCheckbox implements Checkbox { ... }
class WindowsTextField implements TextField { ... }

// Mac implementations
class MacButton implements Button { ... }
class MacCheckbox implements Checkbox { ... }
class MacTextField implements TextField { ... }

// Abstract factory
interface UIFactory {
    Button createButton();
    Checkbox createCheckbox();
    TextField createTextField();
}

// Concrete factories — each creates a matching family
class WindowsUIFactory implements UIFactory {
    public Button createButton()     { return new WindowsButton(); }
    public Checkbox createCheckbox() { return new WindowsCheckbox(); }
    public TextField createTextField() { return new WindowsTextField(); }
}

class MacUIFactory implements UIFactory {
    public Button createButton()     { return new MacButton(); }
    public Checkbox createCheckbox() { return new MacCheckbox(); }
    public TextField createTextField() { return new MacTextField(); }
}

// Application is platform-agnostic
class Application {
    private final UIFactory factory;

    public Application(UIFactory factory) {
        this.factory = factory;
    }

    public void buildLoginForm() {
        Button loginButton = factory.createButton();
        TextField emailField = factory.createTextField();
        Checkbox rememberMe = factory.createCheckbox();
        // Render consistently — all components match the same platform
    }
}

// Wiring
UIFactory factory = System.getProperty("os.name").contains("Mac")
    ? new MacUIFactory()
    : new WindowsUIFactory();

Application app = new Application(factory);

When to choose Abstract Factory over Factory Method:

  • You need to create multiple related objects that must be consistent with each other
  • You want to swap entire product families at once (all Windows → all Mac)
  • Factory Method creates one product; Abstract Factory creates a coordinated suite

Pattern 4: Builder

Intent: Construct a complex object step by step. The Builder pattern separates construction from representation, allowing the same construction process to create different representations.

When to use:

  • An object has many parameters, especially optional ones
  • You want to avoid telescoping constructors (constructors with 5, 6, 7 parameters)
  • Object construction requires validation across multiple fields
  • You want to build immutable objects with many attributes

Example: HTTP Request Builder

java
class HttpRequest {
    // Immutable once built
    private final String url;          // required
    private final String method;       // required
    private final Map<String, String> headers;  // optional
    private final String body;         // optional
    private final int timeoutMs;       // optional, default 30000
    private final boolean followRedirects;       // optional, default true
    private final int maxRetries;      // optional, default 3

    private HttpRequest(Builder builder) {
        this.url = builder.url;
        this.method = builder.method;
        this.headers = Collections.unmodifiableMap(builder.headers);
        this.body = builder.body;
        this.timeoutMs = builder.timeoutMs;
        this.followRedirects = builder.followRedirects;
        this.maxRetries = builder.maxRetries;
    }

    public static class Builder {
        // Required
        private final String url;
        private final String method;
        // Optional with defaults
        private Map<String, String> headers = new HashMap<>();
        private String body = null;
        private int timeoutMs = 30_000;
        private boolean followRedirects = true;
        private int maxRetries = 3;

        public Builder(String url, String method) {
            if (url == null || url.isBlank()) throw new IllegalArgumentException("URL required");
            if (method == null || method.isBlank()) throw new IllegalArgumentException("Method required");
            this.url = url;
            this.method = method.toUpperCase();
        }

        public Builder header(String key, String value) {
            this.headers.put(key, value); return this;
        }

        public Builder body(String body) { this.body = body; return this; }
        public Builder timeout(int ms) { this.timeoutMs = ms; return this; }
        public Builder followRedirects(boolean follow) { this.followRedirects = follow; return this; }
        public Builder maxRetries(int n) { this.maxRetries = n; return this; }

        public HttpRequest build() {
            // Cross-field validation
            if ("GET".equals(method) && body != null) {
                throw new IllegalStateException("GET requests cannot have a body");
            }
            return new HttpRequest(this);
        }
    }
}

// Fluent, readable construction
HttpRequest request = new HttpRequest.Builder("https://api.example.com/users", "POST")
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .body("{"name": "Yash"}")
    .timeout(5_000)
    .maxRetries(2)
    .build();

Compare with telescoping constructors:

java
// Without Builder — which argument is which? Easy to pass wrong order.
new HttpRequest("https://...", "POST", headers, body, 5000, true, 2);

Pattern 5: Prototype

Intent: Create new objects by copying (cloning) an existing object, rather than creating from scratch.

When to use:

  • Object creation is expensive (involves DB calls, network requests, complex initialisation)
  • You want many similar objects that differ only slightly
  • The system should be independent of how its objects are created and represented

Example: Document Template System

java
interface Cloneable<T> {
    T deepCopy();
}

class DocumentTemplate implements Cloneable<DocumentTemplate> {
    private String title;
    private List<String> sections;     // Must be deep copied
    private Map<String, String> metadata;
    private FormattingConfig formatting; // Shared config — shallow copy OK

    // Deep copy constructor
    private DocumentTemplate(DocumentTemplate source) {
        this.title = source.title;
        this.sections = new ArrayList<>(source.sections);   // new list
        this.metadata = new HashMap<>(source.metadata);     // new map
        this.formatting = source.formatting;                 // shared ref OK
    }

    public DocumentTemplate deepCopy() {
        return new DocumentTemplate(this);
    }

    public DocumentTemplate withTitle(String title) {
        DocumentTemplate copy = this.deepCopy();
        copy.title = title;
        return copy;
    }
}

// Template registry — expensive templates loaded once
class TemplateRegistry {
    private final Map<String, DocumentTemplate> templates = new HashMap<>();

    public void register(String name, DocumentTemplate template) {
        templates.put(name, template);
    }

    public DocumentTemplate get(String name) {
        // Return a clone — each caller gets their own copy to modify
        return templates.get(name).deepCopy();
    }
}

// Usage — no expensive initialisation per document
DocumentTemplate invoice = registry.get("INVOICE_TEMPLATE")
    .withTitle("Invoice #12345");

Shallow vs Deep Copy — the critical distinction:

  • Shallow copy — new object, but shared references to nested objects. Modifying a nested list in the copy modifies the original too.
  • Deep copy — new object with new copies of all nested objects. Fully independent.

Always deep copy mutable fields; shallow copying immutable fields (Strings, primitives, shared configs) is fine.

Choosing Between Patterns

| Pattern | Creates | Use When |

|---------|---------|----------|

| Singleton | 1 instance ever | Shared resource, expensive init |

| Factory Method | 1 object, type varies | Exact type unknown until runtime |

| Abstract Factory | Family of objects | Multiple related objects must match |

| Builder | 1 complex object | Many optional parameters, immutability |

| Prototype | Copy of existing | Expensive init, many similar objects |

Interview Tip

For a Food Delivery LLD, you might say: "I'll use a Factory Method for creating different order types — FoodOrder, GroceryOrder, MedicineOrder — because the OrderService shouldn't know which concrete type to create. I'll use a Builder for creating an Order since it has many optional fields: delivery instructions, coupon code, preferred payment method, scheduled time. And I'll use Singleton for the RateLimiter since there should be exactly one across the application."

Naming three patterns and justifying each in 30 seconds signals fluency to any interviewer.