Lesson 15 of 38 12 minDesign Track

LLD Mastery: The Factory Design Pattern

Master the Factory pattern for Low-Level Design. Learn how to decouple object creation from usage, enabling scalable and testable Java architectures.

Reading Mode

Hide the curriculum rail and keep the lesson centered for focused reading.

Key Takeaways

  • The Factory pattern decouples object creation from execution logic, promoting compliance with the Open/Closed Principle.
  • Dynamic plugin frameworks can use factory registries mapping configuration keys to concrete classes loaded dynamically at runtime.
  • Modern frameworks shift manual factories to Dependency Injection containers, but factory abstractions remain essential for SDK and DDD aggregates.
Recommended Prerequisites
SOLID Principles in Java

Premium outcome

Bridge the gap between architecture diagrams and implementation details.

Engineers preparing for LLD rounds or leveling up their software design depth.

What you unlock

  • Cleaner reasoning around SOLID, patterns, responsibilities, and schema design
  • A usable bridge between HLD whiteboard thinking and concrete Java classes
  • Case-study practice across common interview-style design systems

Object creation is one of the most common actions in software development. However, spreading the new keyword throughout a codebase leads to tight coupling, making the system rigid and difficult to test. The Factory Pattern is a creational design pattern that solves this coupling by delegating instantiation details to dedicated factory objects or methods.

In enterprise architectures, the Factory pattern extends beyond simple conditional instantiation. It serves as the foundation for pluggable microservices, dynamically loaded JAR plugins, and clean domain boundaries.


System Requirements

To design a scalable, pluggable object creation framework using the Factory pattern, we define the following system goals and boundaries:

Functional Requirements

  • Decoupled Creation: The client code must request object instances using abstract interfaces, without knowing or referencing the concrete subclass names.
  • Dynamic Plugin Discovery: The factory must support registering new product types at runtime (e.g., dynamically loading a new payment gateway jar) without requiring recompilation of the main engine.
  • Parametric Instantiation: Support passing runtime configuration parameters to the factory to customize the created object's state dynamically.

Non-Functional Requirements

  • Low Instantiation Latency: Dynamic lookup and creation overhead must be less than 5 milliseconds, ensuring it does not become a bottleneck in high-throughput systems.
  • Memory Footprint Isolation: Dynamically loaded plugins must be isolated in memory, allowing them to be unloaded to prevent permanent JVM Metaspace resource leaks.
  • Thread Safety: The factory registry and creator singletons must be safe to access concurrently by thousands of processing threads.

API Design and Interface Contracts

To allow external plugins and internal services to register and obtain dynamic instances, we define clear interface contracts.

1. Dynamic Factory Plugin Registration (gRPC Protocol)

Used by third-party plugin providers to register their concrete creator classes with the primary application gateway.

syntax = "proto3";

package codesprintpro.factory.registry.v1;

service PluginRegistryService {
  rpc RegisterPlugin (RegisterPluginRequest) returns (RegisterPluginResponse);
  rpc DeregisterPlugin (DeregisterPluginRequest) returns (DeregisterPluginResponse);
}

message RegisterPluginRequest {
  string plugin_id = 1;
  string plugin_name = 2;
  string jar_s3_url = 3;
  string main_class_path = 4; // FQCN: Fully Qualified Class Name
  int64 registry_timestamp = 5;
}

message RegisterPluginResponse {
  bool is_successful = 1;
  string assigned_routing_key = 2;
  string error_message = 3;
}

message DeregisterPluginRequest {
  string plugin_id = 1;
  int64 epoch_timestamp_ms = 2;
}

message DeregisterPluginResponse {
  bool is_successful = 1;
}

2. Client Ingress Gateway Contract (HTTP POST /v1/notifications/dispatch)

Used by internal systems to trigger a notification dispatch, letting the factory select and execute the correct provider.

{
  "notificationId": "notif_908124_prod",
  "userId": "usr_99812",
  "channel": "SMS",
  "payload": {
    "phoneNumber": "+15550199",
    "bodyText": "Your verification code is 449210."
  },
  "retryConfig": {
    "maxAttempts": 3,
    "backoffSeconds": 2
  }
}

High-Level Architecture

The architecture illustrates the transition from tightly coupled monolithic instantiation to a pluggable, class-loader isolated dynamic factory.

1. Monolithic Tight Coupling vs. Decoupled Interface Factory

In a coupled model, the Client explicitly imports and instantiates concrete classes. In the decoupled model, the Client relies on the Factory to instantiate classes implementing a shared Interface.

graph TD
    subgraph Coupled Model
        Client[Client Service] -->|new EmailService| ConcreteA[EmailNotification]
        Client -->|new SMSService| ConcreteB[SMSNotification]
    end

    subgraph Decoupled Model
        ClientD[Client Service] -->|Request SMS| Factory[NotificationFactory]
        Factory -->|Instantiates| ConcreteC[SMSNotification]
        ConcreteC -.->|Implements| Inter[Notification Interface]
        ClientD -->|Uses Interface| Inter
    end

2. Dynamic Class Loader Plugin Factory Pipeline

When a new notification channel (e.g., Push) is registered, the dynamic factory uses a dedicated ClassLoader to resolve the class path and register it in the creator map.

sequenceDiagram
    autonumber
    participant System as Application Engine
    participant Registry as Plugin Factory Registry
    participant Loader as Custom Jar ClassLoader
    participant S3 as Storage Bucket (S3)

    System->>Registry: Request Product Instance ("PUSH")
    alt Registry contains "PUSH"
        Registry-->>System: Return Cached Creator Instance
    else Registry empty for "PUSH"
        Registry->>S3: Download Plugin JAR
        S3-->>Registry: Return JAR binary bytes
        Registry->>Loader: Load JAR & Resolve Class "com.push.PushNotification"
        Loader-->>Registry: Return Class Metadata & Constructor
        Registry->>Registry: Cache Instance in Creators Map
        Registry-->>System: Return Dynamic Product Instance
    end
    System->>System: Execute product.send()

Low-Level Design and Schema

To manage the runtime state of dynamic factories and class mappings, we store plugin metadata in a relational PostgreSQL database.

-- Core registry for all dynamically loadable plugin creators
CREATE TABLE factory_plugins (
    plugin_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    plugin_name VARCHAR(128) NOT NULL UNIQUE,
    routing_key VARCHAR(64) NOT NULL UNIQUE, -- E.g., 'EMAIL', 'SMS', 'PUSH'
    jar_location_url VARCHAR(512) NOT NULL, -- S3 URL to download class jar
    fully_qualified_class_path VARCHAR(256) NOT NULL, -- E.g., 'com.codesprintpro.EmailNotification'
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_plugins_routing ON factory_plugins(routing_key) WHERE is_active = TRUE;

-- Track initialization parameters required by factories for specific plugins
CREATE TABLE plugin_configuration_parameters (
    param_id BIGSERIAL PRIMARY KEY,
    plugin_id UUID NOT NULL REFERENCES factory_plugins(plugin_id) ON DELETE CASCADE,
    config_key VARCHAR(128) NOT NULL,
    config_value VARCHAR(512) NOT NULL,
    is_encrypted BOOLEAN NOT NULL DEFAULT FALSE,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(plugin_id, config_key)
);

CREATE INDEX idx_plugin_params_lookup ON plugin_configuration_parameters(plugin_id);

Design Rationale:

  1. Partial Index (idx_plugins_routing): Restricts the index scanning range exclusively to active plugins. When the factory queries class paths during routing, it avoids scanning historical or disabled plugin metadata.
  2. Dynamic Configurations (plugin_configuration_parameters): Storing initialization configurations in a separate table allows the factory to inject properties (like API keys and timeouts) into the constructor dynamically via reflection.

Scaling Challenges and Capacity Estimation

As we scale a plugin-based dynamic factory framework, we must evaluate JVM memory limits and allocation overheads.

1. Metaspace Capacity Calculations

Dynamic class loading writes metadata directly to the JVM Metaspace. Unlike the heap, Metaspace resides in native memory.

  • Assumptions:

    • Allocated Metaspace limit (-XX:MaxMetaspaceSize) = $256$ MB
    • Average memory footprint of a loaded class and its metadata = $20$ KB
    • Average classes loaded per dynamically generated JAR plugin = $150$ classes
  • Calculations: $$\text{Memory Required per Plugin} = 150 \times 20\text{ KB} = 3,000\text{ KB} \approx 3\text{ MB}$$ $$\text{Maximum Concurrent Plugins} = \frac{256\text{ MB}}{3\text{ MB}} \approx 85\text{ plugins}$$

If the system loads new plugin versions without unloading old class loaders, the JVM will exhaust the native memory, causing a fatal java.lang.OutOfMemoryError: Metaspace. To support infinite deployments, we must use weak references and discard stale ClassLoader instances to trigger Metaspace garbage collection.

2. Heap Garbage Collection Pressure

Using factories to instantiate transient objects in high-throughput loops generates massive garbage collection pressure.

  • Assumptions:

    • Request rate = $100,000$ transactions/second
    • Average size of created product object + dependencies on heap = $10$ KB
  • Calculations: $$\text{Memory Allocation Rate} = 100,000\text{ items/s} \times 10\text{ KB} = 1,000,000\text{ KB/second} \approx 1\text{ GB/second}$$

Allocating 1 GB of short-lived objects per second will trigger frequent Minor GCs, stalling execution threads. To scale, the factory must apply the Flyweight Pattern or Object Pool Pattern, caching and reusing immutable product instances where possible.


Failure Scenarios and Resilience

Dynamic factory implementations introduce unique run-time failure modes that require defensive coding.

1. Class Loading Failures (ClassNotFoundException)

  • The Threat: A plugin JAR downloaded from S3 is corrupted or lacks the specified class path, causing the factory's reflection initializer to fail when processing requests.
  • Resilience Design:
    • Implement a Fallback Registry.
    • If reflection fails, catch the instantiation error, log the incident to metrics, and return a robust DefaultFallbackNotification class that uses a reliable local channel, keeping the client transaction alive.

2. ClassLoader Metaspace Leak (Metaspace Exhaustion)

  • The Threat: Custom class loaders are kept in memory because active instances of classes created by them are referenced in long-lived application scopes. This prevents the class loader and class metadata from being garbage collected.
  • Resilience Design:
    • Ensure all factory-created instances are short-lived and dereferenced immediately after execution.
    • Wrap class loader references in WeakReference structures within the factory registry, allowing the garbage collector to sweep them when the plugin is deactivated.

Architectural Trade-offs

Choosing the correct creational abstraction is a balance between simplicity and extension capabilities.

Trade-off 1: Simple Factory vs. Factory Method vs. Abstract Factory

Pattern Type Extension Mechanism Complexity Best Use Case
Simple Factory Static method with switch-case blocks. Low. Simple to write and verify. Small, static families of classes (e.g., parsing XML vs. JSON).
Factory Method Subclassing and polymorphic overrides. Medium. Requires parallel class hierarchies. Extending frameworks where users inject custom implementations.
Abstract Factory Interfaces managing groups of related factory methods. High. Extensive interface boilerplate. Creating families of related, dependent objects (e.g., UI component kits).

Trade-off 2: Static Compile-Time Mapping vs. Dynamic Reflection Loading

Metric Static Mapping (Enum/Switch) Dynamic Reflection Loading
Compilation Boundary Monolithic. Adding a type requires rebuilding the system. Pluggable. New types are added dynamically at runtime.
Execution Performance High. Native JVM calls with zero reflection overhead. Low. Reflection instantiation is slower (approx. 2x to 5x overhead).
Security Risk Low. Only compiled classes can be executed. High. Risk of running arbitrary malicious classes from loaded JARs.

Staff Engineer Perspective

Designing creational systems at scale requires looking past design patterns to understand runtime mechanics.


Verbal Script

Interviewer: "How do you design a dynamic plugin factory in Java that can load and instantiate classes from external JAR files at runtime without restarting the application?"

Candidate: "To build a dynamic plugin factory, I would combine a custom URLClassLoader with a thread-safe registry map.

First, I would define a standard product interface that all plugins must implement.

When a new plugin is uploaded, the factory downloads the JAR file, instantiates a new URLClassLoader pointing to that JAR, and uses reflection—specifically Class.forName with the target class path and the custom class loader—to load the class.

Once loaded, I would retrieve the default constructor, cast it to the shared interface, and store the constructor handle in a concurrent registry map.

To prevent memory leaks and Metaspace exhaustion, I must ensure that each plugin gets its own class loader instance.

When a plugin is updated or disabled, the factory removes the entry from the registry map and discards the class loader reference.

As long as the application code does not maintain references to the loaded class instances, the garbage collector will unload the classes and sweep the native Metaspace allocation."


Interviewer: "Explain the difference between a Factory Method and an Abstract Factory. When should you use one over the other?"

Candidate: "The primary difference lies in the scope and complexity of the object creation.

The Factory Method uses inheritance and polymorphism. It defines a single method within a creator class that defers object instantiation to subclass implementations.

We use it when a class cannot anticipate the exact class of objects it must create, allowing subclasses to override the creation logic.

The Abstract Factory uses composition. It provides an interface to create families of related or dependent objects without specifying their concrete classes.

It is essentially an object containing multiple factory methods.

We choose the Abstract Factory pattern when the system must configure families of products that are designed to work together, and we need to enforce consistency—for instance, ensuring that a DarkThemeFactory creates only DarkButton, DarkCheckbox, and DarkScrollbar objects, rather than mixing them with LightTheme components."


Interviewer: "How do you handle performance bottlenecks when using reflection inside factories to instantiate classes dynamically?"

Candidate: "Reflection is slow because the JVM must perform security checks, class path lookups, and parameter conversions at runtime.

To bypass this bottleneck, I avoid running reflection inside the request-handling path.

Instead, I use Reflection Caching with MethodHandles.

During the bootstrap or plugin registration phase, the factory performs the expensive lookup operation once to find the class constructor.

I then retrieve a MethodHandle and bind it, or convert it to a lambda function using LambdaMetafactory.

This lambda acts as a direct, compiled factory method.

I cache this lambda in a ConcurrentHashMap keyed by the product identifier.

When a request arrives, the factory retrieves the pre-compiled lambda from the cache and executes it.

This approach delivers performance that is close to native compile-time new operations, bypassing the JVM reflection overhead entirely."


Want to track your progress?

Sign in to save your progress, track completed lessons, and pick up where you left off.