jongkwan.dev
Development · Essay №005

Creational Patterns - Encapsulating Object Creation

Singleton, Factory, Builder, Prototype, Object Pool, and DI - a full tour of the creational patterns

Jongkwan Lee2025년 3월 2일11 min read
Contents

A tour of the five GoF creational patterns that encapsulate the complexity of object creation, plus the modern alternatives of dependency injection and functional composition.

Overview

Creational patterns encapsulate the complexity of creating objects, which keeps the rest of the system independent of what gets created and how. This post covers the five creational patterns defined by the Gang of Four (GoF), together with two modern alternatives: dependency injection (DI) and functional composition. Once creation logic lives in one place, changing the type of object being created no longer forces changes in the calling code.

The Singleton pattern

Definition

A pattern that guarantees a class has exactly one instance and provides a global access point to it.

Where it is used

Singleton shows up wherever an expensive resource is shared or global state has to be managed.

  • Database connection pool: connections are expensive to create, so the pool is shared
  • Logger: consistent logging across the whole application
  • Configuration manager: a single access point for application settings
  • Cache manager: sharing a Redis client instance

Implementation example

The example below implements a connection pool as a Singleton. getInstance() uses double-checked locking so it enters the lock only when instance is missing. The volatile field guarantees that the constructed instance is fully visible to other threads.

java
public class DatabasePool {
    private static volatile DatabasePool instance;
    private final HikariDataSource dataSource;
 
    private DatabasePool() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost/mydb");
        this.dataSource = new HikariDataSource(config);
    }
 
    // Double-checked locking (multithread safe)
    public static DatabasePool getInstance() {
        if (instance == null) {
            synchronized (DatabasePool.class) {
                if (instance == null) {
                    instance = new DatabasePool();
                }
            }
        }
        return instance;
    }
 
    public Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }
}

Problems with Singleton

ProblemDescription
Hard to testGlobal state makes test isolation difficult
Hidden dependenciesReachable from anywhere, so dependencies are hard to trace
Multithreading issuesRequires concurrency control (double-checked locking, volatile)
SOLID violationCan violate the Single Responsibility Principle (SRP) by mixing instance management with business logic
Tight couplingGlobal access produces strong coupling

The modern alternative: scope management in a DI container

java
// Spring: singleton scope (the default)
@Component
@Scope("singleton") // the DI container manages the instance
public class DatabasePool {
    // no need to implement the Singleton pattern by hand
    // easy to replace with a mock in tests
}

The Factory Method pattern

Definition

A pattern that defines an interface for creating an object while delegating the choice of concrete class to subclasses.

The problem

java
// Direct construction: adding a new type requires editing every creation site
Notification notification;
if (type.equals("email")) {
    notification = new EmailNotification();
} else if (type.equals("sms")) {
    notification = new SmsNotification();
}
// another else if for every new type... -> violates the Open-Closed Principle (OCP)

Applying Factory Method

java
// Define the creation interface
public abstract class NotificationFactory {
    public abstract Notification createNotification();
 
    // Can be combined with the template method pattern
    public void send(String message) {
        Notification notification = createNotification();
        notification.setMessage(message);
        notification.deliver();
    }
}
 
public class EmailNotificationFactory extends NotificationFactory {
    @Override
    public Notification createNotification() {
        return new EmailNotification();
    }
}
 
public class SmsNotificationFactory extends NotificationFactory {
    @Override
    public Notification createNotification() {
        return new SmsNotification();
    }
}

Where it is used

  • Creating user-defined objects inside a framework (Spring's BeanFactory)
  • Creating a per-provider client when integrating payment gateways
  • Per-document-type creators in a document editor

The Abstract Factory pattern

Definition

A pattern that provides an interface for creating families of related objects without naming their concrete classes. It is Factory Method raised one level of abstraction.

Implementation example: a cross-platform UI

java
// Abstract factory
public interface UIFactory {
    Button createButton();
    TextField createTextField();
    Dialog createDialog();
}
 
// Concrete factory: Windows platform
public class WindowsUIFactory implements UIFactory {
    public Button createButton() { return new WindowsButton(); }
    public TextField createTextField() { return new WindowsTextField(); }
    public Dialog createDialog() { return new WindowsDialog(); }
}
 
// Concrete factory: macOS platform
public class MacUIFactory implements UIFactory {
    public Button createButton() { return new MacButton(); }
    public TextField createTextField() { return new MacTextField(); }
    public Dialog createDialog() { return new MacDialog(); }
}
 
// Client code: does not depend on any concrete factory
public class Application {
    private final UIFactory factory;
 
    public Application(UIFactory factory) {
        this.factory = factory;
    }
 
    public void createUI() {
        Button button = factory.createButton();
        TextField field = factory.createTextField();
    }
}

Factory Method versus Abstract Factory

AspectFactory MethodAbstract Factory
Creation scopeA single productA product family (several related objects)
Level of abstractionMethod levelObject (factory) level
How it extendsAdd a subclassImplement a new factory
Consistency guaranteePer productAcross related products

The Builder pattern

Definition

A pattern that builds a complex object step by step, allowing the same construction procedure to produce different representations.

The problem: the telescoping constructor

java
// Readability collapses as the parameter count grows
new HttpRequest("GET", "/api/users", null, headers, timeout, retry, cache, auth);

Applying Builder

java
public class HttpRequest {
    private final String method;
    private final String url;
    private final Map<String, String> headers;
    private final int timeout;
    private final int retryCount;
    private final boolean cacheEnabled;
 
    private HttpRequest(Builder builder) {
        this.method = builder.method;
        this.url = builder.url;
        this.headers = Map.copyOf(builder.headers); // immutable copy
        this.timeout = builder.timeout;
        this.retryCount = builder.retryCount;
        this.cacheEnabled = builder.cacheEnabled;
    }
 
    public static class Builder {
        private final String method; // required
        private final String url;    // required
        private Map<String, String> headers = new HashMap<>();
        private int timeout = 30000;
        private int retryCount = 3;
        private boolean cacheEnabled = false;
 
        public Builder(String method, String url) {
            this.method = method;
            this.url = url;
        }
 
        public Builder header(String key, String value) {
            this.headers.put(key, value);
            return this; // fluent API
        }
 
        public Builder timeout(int ms) {
            this.timeout = ms;
            return this;
        }
 
        public Builder retry(int count) {
            this.retryCount = count;
            return this;
        }
 
        public Builder cache(boolean enabled) {
            this.cacheEnabled = enabled;
            return this;
        }
 
        public HttpRequest build() {
            // validation
            if (method == null || url == null) {
                throw new IllegalStateException("method and url are required");
            }
            return new HttpRequest(this);
        }
    }
}
 
// Usage: a readable fluent API
HttpRequest request = new HttpRequest.Builder("POST", "/api/orders")
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer token")
    .timeout(5000)
    .retry(5)
    .cache(true)
    .build();

Advantages of Builder

  • Well suited to creating immutable objects
  • Clear separation between required and optional parameters
  • Better readability through a fluent API
  • Combines with Lombok's @Builder in Java and data class in Kotlin

The Prototype pattern

Definition

A pattern that creates a new object by cloning an existing one. It pays off when object creation is expensive.

Where it is used

The example clones a document template to produce a new document. clone() performs a deep copy that recurses into nested objects, and the registry looks up a registered prototype by key and returns a copy of it.

java
public interface DocumentPrototype extends Cloneable {
    DocumentPrototype clone();
}
 
public class SpreadsheetTemplate implements DocumentPrototype {
    private List<Sheet> sheets;
    private Map<String, Style> styles;
    private Configuration config;
 
    // Deep copy
    @Override
    public SpreadsheetTemplate clone() {
        SpreadsheetTemplate copy = new SpreadsheetTemplate();
        copy.sheets = this.sheets.stream()
            .map(Sheet::clone)
            .collect(Collectors.toList());
        copy.styles = new HashMap<>(this.styles);
        copy.config = this.config.clone();
        return copy;
    }
}
 
// Prototype registry
public class TemplateRegistry {
    private final Map<String, DocumentPrototype> templates = new HashMap<>();
 
    public void register(String key, DocumentPrototype prototype) {
        templates.put(key, prototype);
    }
 
    public DocumentPrototype create(String key) {
        return templates.get(key).clone();
    }
}

Deep copy versus shallow copy

Copy styleDescriptionCaveats
Shallow copyCopies references onlyOriginal and copy share nested objects
Deep copyRecursively copies every nested objectWatch for cycles, and it is expensive

The Object Pool pattern

Definition

A pattern that creates expensive objects ahead of time and keeps them in a pool, lending and returning them on demand. It is not in the GoF catalog, but it matters a great deal in high-performance systems.

Where it is used

The example pre-creates connections in a pool and lends them out. acquire() takes an available connection and hands it over. release() returns a still-valid connection to the pool and replaces a broken one with a freshly created connection.

java
public class ConnectionPool {
    private final Queue<Connection> available = new ConcurrentLinkedQueue<>();
    private final Set<Connection> inUse = ConcurrentHashMap.newKeySet();
    private final int maxSize;
 
    public ConnectionPool(int maxSize) {
        this.maxSize = maxSize;
        // create the initial connections
        for (int i = 0; i < maxSize; i++) {
            available.add(createConnection());
        }
    }
 
    public synchronized Connection acquire() {
        Connection conn = available.poll();
        if (conn != null) {
            inUse.add(conn);
            return conn;
        }
        throw new PoolExhaustedException("No available connections");
    }
 
    public synchronized void release(Connection conn) {
        inUse.remove(conn);
        if (conn.isValid()) {
            available.offer(conn);
        } else {
            available.offer(createConnection()); // replace the broken connection
        }
    }
}

Well-known implementations

  • HikariCP: the widely used high-performance JDBC connection pool for Java
  • gRPC channel pool: connection pooling for gRPC
  • Thread pool: Executors.newFixedThreadPool() and friends

Dependency injection as a pattern

From Factory to DI

Dependency injection moves the responsibility for creating objects from a factory to a container. Where a factory decides "what to create", DI delegates "who creates it" to an external container.

Three forms of DI

java
// 1. Constructor injection -- the recommended default
public class OrderService {
    private final PaymentGateway paymentGateway;
    private final InventoryService inventoryService;
 
    public OrderService(PaymentGateway paymentGateway,
                        InventoryService inventoryService) {
        this.paymentGateway = paymentGateway;
        this.inventoryService = inventoryService;
    }
}
 
// 2. Setter injection -- optional dependencies
public class ReportService {
    private CacheService cache;
 
    public void setCacheService(CacheService cache) {
        this.cache = cache;
    }
}
 
// 3. Method injection -- decided at call time
public class NotificationService {
    public void send(NotificationChannel channel, String message) {
        channel.deliver(message);
    }
}

The DI container ecosystem (2026)

FrameworkLanguageCharacteristics
Spring / Spring BootJava, KotlinThe standard inversion of control (IoC) container, with auto-configuration
NestJSTypeScriptDecorator-based DI, influenced by Angular
.NET DIC#Built-in DI container
Dagger / HiltAndroid (Kotlin)Compile-time DI, optimized for performance
WireGoBased on compile-time code generation

Functional composition versus inheritance

The modern view: composition over inheritance

As of 2025, the influence of functional programming has made alternatives to inheritance-based creational patterns widespread.

Partial application

typescript
// Object creation through partial application instead of a factory
const createLogger = (level: string) => (module: string) => (message: string) => {
    console.log(`[${level}][${module}] ${message}`);
};
 
const errorLogger = createLogger("ERROR");
const authErrorLogger = errorLogger("auth");
authErrorLogger("Login failed"); // [ERROR][auth] Login failed

DI through the Reader monad

Functional programming implements dependency injection with the Reader monad:

typescript
// Pass dependencies as an environment
type Reader<Env, A> = (env: Env) => A;
 
interface AppEnv {
    db: Database;
    logger: Logger;
    cache: CacheService;
}
 
const getUser: Reader<AppEnv, Promise<User>> =
    (env) => env.db.query("SELECT * FROM users WHERE id = ?");
 
const logAndGetUser: Reader<AppEnv, Promise<User>> =
    (env) => {
        env.logger.info("Fetching user");
        return getUser(env);
    };

Kleisli composition

A pattern for composing functions that return monads, which lets independent functions be assembled in several ways.

typescript
// Each function is implemented independently
const validate = (input: RawOrder): Result<ValidOrder> => { /* ... */ };
const enrich = (order: ValidOrder): Result<EnrichedOrder> => { /* ... */ };
const persist = (order: EnrichedOrder): Result<SavedOrder> => { /* ... */ };
 
// Build the pipeline through Kleisli composition
const processOrder = compose(validate, enrich, persist);

Choosing a creational pattern

SituationRecommended pattern
Exactly one instance is neededSingleton scope in a DI container (avoid hand-written Singleton)
The object type is decided at runtimeFactory Method
A family of related objectsAbstract Factory
An immutable object with many parametersBuilder
Cloning an existing objectPrototype
An expensive resourceObject Pool
Testability is the priorityDependency injection
Functional stylePartial application, Reader monad

Summary

Creational patterns separate how an object is built from the code that uses it, so a change in construction does not ripple into the call sites. When only one instance is wanted, the Singleton scope of a DI container beats a hand-written Singleton on both testability and coupling. Immutable objects with many parameters go to Builder, construction whose type is decided at runtime goes to Factory Method, and families of related objects go to Abstract Factory. Expensive resources get reused through an Object Pool, and when testability comes first, dependency injection or functional composition is the better choice.

The next post covers structural patterns.