Creational Design Patterns: Factory Method, Abstract Factory, Builder, Singleton, Prototype
Modern, cross-language treatment of the classic creational Gang of Four patterns, including idiomatic replacements in Python, TypeScript, Go, and Rust, and an honest assessment of why Singleton is discouraged in testable, DI-based codebases in 2026.
Introduction / Overview
Creational patterns address how objects are constructed, so that the rest of the system depends on interfaces for construction rather than concrete class names and new/__init__ calls scattered throughout the codebase. The original five patterns from the 1994 Gang of Four (GoF) catalogue - Factory Method, Abstract Factory, Builder, Singleton, and Prototype - remain relevant in 2026, but their idiomatic implementation has changed considerably: Python favors Protocol classes and factory functions over class hierarchies, Go favors functional options over a Builder object, Rust encodes construction validity in the type system (the typestate pattern), and Singleton has fallen out of favor almost everywhere in favor of dependency injection.
This document covers each pattern's intent, when it earns its complexity, and its most idiomatic 2026 expression per language - with particular emphasis on why Singleton is now widely discouraged in codebases that value unit testability.
When to use this guidance: Whenever object construction logic (which concrete type to instantiate, how to assemble a complex object, how to guarantee a single shared instance) starts leaking into business logic, or when you are deciding between a language-native shortcut (dataclass defaults, functional options) and a formal GoF pattern.
Core Concepts
- Construction is a responsibility of its own: Mixing "how to build a
User" with "what aUserdoes" violates the Single Responsibility Principle. Creational patterns exist to isolate that responsibility. - Program to an interface, not an implementation: Every creational pattern here is, at its core, a way to let calling code depend on an abstraction (
Protocol,trait,interface) while a factory or builder decides the concrete type. - Not every "creational need" needs a GoF pattern: Python keyword arguments with defaults, Go functional options, and Rust's
Defaulttrait often replace what would have been a Builder or Factory class in Java-era code. Applying KISS means recognizing when the language already solves the problem. - Global mutable state is the real hazard, not "having one instance": Singleton conflates two separate ideas - "there is conceptually one of these" (often fine) and "any code anywhere can reach it via a global accessor" (almost always a testability and coupling problem).
Best Practices
1. Factory Method - Defer Instantiation to Subclasses or Injected Callables
Rationale: When the concrete type to construct depends on runtime information (configuration, input data, environment), centralize that decision in one place rather than repeating if/elif chains across the codebase.
from typing import Protocol
class NotificationSender(Protocol):
async def send(self, to: str, message: str) -> None: ...
class EmailSender:
async def send(self, to: str, message: str) -> None: ...
class SmsSender:
async def send(self, to: str, message: str) -> None: ...
def create_sender(channel: str) -> NotificationSender:
if channel == "email":
return EmailSender()
if channel == "sms":
return SmsSender()
raise ValueError(f"Unsupported channel: {channel}")
Why this works well in production: Callers depend only on NotificationSender; adding a PushSender touches one function, satisfying the Open/Closed Principle for callers even though create_sender itself must change (this is the expected, contained extension point).
2. Abstract Factory - Produce Families of Related Objects Consistently
Rationale: When a system must swap an entire family of related objects together (e.g., all UI widgets for a theme, or all repositories for a specific storage backend), Abstract Factory guarantees the family stays consistent - you cannot accidentally mix a Postgres repository with a MongoDB unit-of-work.
interface UserRepository { findById(id: string): Promise<User | null>; }
interface OrderRepository { findByUser(userId: string): Promise<Order[]>; }
interface DataAccessFactory {
createUserRepository(): UserRepository;
createOrderRepository(): OrderRepository;
}
class PostgresDataAccessFactory implements DataAccessFactory {
createUserRepository(): UserRepository { return new PostgresUserRepository(); }
createOrderRepository(): OrderRepository { return new PostgresOrderRepository(); }
}
class InMemoryDataAccessFactory implements DataAccessFactory {
createUserRepository(): UserRepository { return new InMemoryUserRepository(); }
createOrderRepository(): OrderRepository { return new InMemoryOrderRepository(); }
}
Why this works well in production: Swapping PostgresDataAccessFactory for InMemoryDataAccessFactory in tests replaces an entire consistent family of collaborators in one line - see Testing Strategies.
3. Builder - Assemble Complex Objects Step by Step
Rationale: When an object has many optional parameters, invalid intermediate states are possible, or construction involves several ordered steps, a Builder makes the construction process explicit and readable, and can enforce validity before the final object exists.
// Go idiom: functional options rather than a distinct Builder object.
type ServerConfig struct {
host string
port int
timeout time.Duration
tls bool
}
type Option func(*ServerConfig)
func WithPort(port int) Option { return func(c *ServerConfig) { c.port = port } }
func WithTLS() Option { return func(c *ServerConfig) { c.tls = true } }
func NewServerConfig(host string, opts ...Option) *ServerConfig {
cfg := &ServerConfig{host: host, port: 8080, timeout: 30 * time.Second}
for _, opt := range opts {
opt(cfg)
}
return cfg
}
// Usage
cfg := NewServerConfig("api.internal", WithPort(9443), WithTLS())
// Rust: typestate builder -- invalid states are unrepresentable at compile time.
struct NoHost;
struct WithHost(String);
struct ServerConfigBuilder<H> {
host: H,
port: u16,
}
impl ServerConfigBuilder<NoHost> {
fn new() -> Self { Self { host: NoHost, port: 8080 } }
fn host(self, host: impl Into<String>) -> ServerConfigBuilder<WithHost> {
ServerConfigBuilder { host: WithHost(host.into()), port: self.port }
}
}
impl ServerConfigBuilder<WithHost> {
fn port(mut self, port: u16) -> Self { self.port = port; self }
fn build(self) -> ServerConfig {
ServerConfig { host: self.host.0, port: self.port }
}
}
// .build() is only callable once .host(...) has been called -- enforced by the type system.
Why this works well in production: In Go, functional options avoid an entire Builder type while keeping call sites readable and backward-compatible (new options never break existing callers). In Rust, the typestate variant moves a runtime validation ("host must be set") into a compile-time guarantee. In Python, prefer a @dataclass with sensible defaults and, only for genuinely multi-step or validated construction, a dedicated builder.
4. Singleton - Prefer Dependency Injection; Use Sparingly and Explicitly
Rationale: The classic Singleton (a class that hides a private constructor and exposes a static getinstance()) solves a real problem (ensure exactly one instance of an expensive or stateful resource) with a mechanism that creates hidden global coupling. Any code, anywhere, can call Database.getinstance(), which means:
- Tests cannot easily substitute a fake instance without global monkeypatching.
- The dependency is invisible in a function's signature, violating Dependency Inversion in spirit even if not in letter.
- Global mutable state introduces subtle ordering and concurrency bugs, especially under
asyncioor goroutines where "singleton" initialization order is not guaranteed.
Discouraged (classic Singleton):
class DatabaseConnection:
_instance: "DatabaseConnection | None" = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
# Anywhere in the codebase:
db = DatabaseConnection() # hidden global dependency, hard to fake in tests
**Preferred (singleton lifetime, managed by the composition root, injected explicitly)**:
from fastapi import FastAPI, Depends
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.db_pool = await create_db_pool() # created exactly once
yield
await app.state.db_pool.close()
def get_db_pool(request: Request) -> DatabaseConnectionPool:
return request.app.state.db_pool # explicit, overridable in tests
app = FastAPI(lifespan=lifespan)
This achieves the goal of Singleton (one connection pool for the app's lifetime - see the FastAPI lifespan pattern) without a globally reachable static accessor: the dependency is explicit in the function signature and trivially overridden via app.dependencyoverrides in tests.
Why this works well in production: DI containers and frameworks (FastAPI's Depends, most modern DI libraries in Go/TypeScript/Java) already manage instance lifetime (singleton, request-scoped, transient) as a configuration, not a hard-coded class shape - giving you the benefit of Singleton without its testability cost.
5. Prototype - Clone Configured Objects Instead of Rebuilding Them
Rationale: When creating a new object is expensive (deep configuration, computed derived state) but a very similar object already exists, cloning and adjusting is cheaper and less error-prone than re-running the full construction path.
import copy
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class ReportTemplate:
layout: str
sections: tuple[str, ...]
theme: str
base_invoice_template = ReportTemplate(layout="A4", sections=("header", "items", "totals"), theme="default")
# Prototype via immutable "replace" -- safer than mutable deep copy
dark_theme_invoice_template = replace(base_invoice_template, theme="dark")
Why this works well in production: Using an immutable dataclass with replace() (or Rust's Clone + struct update syntax, or TypeScript's spread operator { ...base, theme: "dark" }) gives you Prototype's benefit - cheap derivation from a known-good configuration - without the classic GoF pattern's mutable deep-clone machinery and its risk of accidental shared references.
Common Pitfalls & Anti-Patterns
Pitfall: Reaching for Singleton to Avoid "Passing Parameters"
Problem: Developers introduce a Singleton purely to avoid threading a dependency (logger, config, DB session) through several function calls, prioritizing short-term convenience over testability.
Recommended approach: Use dependency injection at the composition root (see SOLID's DIP); most modern frameworks provide request-scoped or app-scoped DI containers precisely to avoid this trade-off.
Pitfall: A Builder for Objects With No Real Optional Complexity
Problem: Wrapping a 3-field object in a full Builder class because "that's how you construct objects" adds ceremony without benefit - a YAGNI violation.
Recommended approach: Use the language's native construct (Python @dataclass with defaults, TypeScript object literal with a typed interface, Go struct literal) until real multi-step or validated construction complexity justifies a Builder.
Pitfall: Abstract Factory Used for a Single Family That Will Never Vary
Problem: Introducing an AbstractFactory + concrete factory hierarchy when there is, and will only ever be, one backing implementation (e.g., one database) adds indirection with no payoff.
Recommended approach: Start with direct construction or a simple Factory Method; escalate to Abstract Factory only once a second, genuinely swappable family exists (again, the Rule of Three).
Testing Strategies
- Factory Method / Abstract Factory: Test by substituting a fake factory that returns test doubles; assert the calling code behaves correctly regardless of which concrete family is supplied.
- Builder: Test both the fluent/typestate construction path (valid combinations produce a correctly configured object) and, where applicable, that invalid states are rejected (either at compile time in Rust, or via a raised exception/validation error in Python/TypeScript).
- Singleton replacements (DI): Because the dependency is explicit (constructor or
Depends()parameter), tests substitute a fake/in-memory implementation directly - no monkeypatching of class internals or global state required. See FastAPI dependency overrides. - Prototype: Assert that cloned objects are independent (mutating the clone does not affect the original) - a common regression when using shallow copy instead of proper immutable
replace/Clonesemantics. - Pair with Unit Testing Principles for isolation and fixture design.
Performance, Security & Scalability Considerations
- Singleton lifetime vs concurrency: A true app-lifetime singleton (connection pool, cache client) is fine and often necessary for performance (avoid reconnect overhead per request); the problem is global accessibility, not the lifetime itself. Manage lifetime via the framework's DI container/lifespan hooks, not a hand-rolled static accessor.
- Factory overhead is negligible: Modern JIT/AOT compilers and interpreters optimize simple factory functions well; the indirection cost is not a real-world bottleneck compared to I/O.
- Builder validation as a security control: A Builder/typestate pattern that refuses to produce an object in an invalid state (e.g., a
HttpClientBuilderthat will notbuild()without TLS configured in a security-sensitive context) prevents entire classes of misconfiguration vulnerabilities at compile or construction time. - Prototype cloning must avoid leaking mutable shared state: Cloning objects that hold references to shared mutable collections (not deep-copied) can cause subtle cross-instance data corruption under concurrent access - prefer immutable value objects (frozen dataclasses,
Arc<T>with copy-on-write in Rust) as prototypes.
Edge Cases & Advanced Usage
- Singleton-like registries in plugin systems: A process-wide plugin registry is a legitimate case for a single shared instance - but expose it through an injectable interface (
PluginRegistryprotocol) rather than a global class accessor, so tests can substitute an empty or fake registry. - Builder combined with Abstract Factory: Complex object graphs (e.g., constructing an entire test fixture with several related repositories) sometimes combine a Builder (for the object being assembled) with an Abstract Factory (for choosing which family of collaborators to use) - this composition is common in integration test setup.
- Prototype for expensive ML model configuration: Cloning a base model configuration object (hyperparameters, preprocessing pipeline) before mutating a few fields for an experiment is a common and effective use of Prototype in data science pipelines.
- Factories returning async-constructed objects: When construction requires
await(e.g., an async health check before returning a ready client), a plain Factory Method becomes an async factory function; this is idiomatic in Python (async def create_client(...) -> Client) and avoids the need for two-phase construction.
When Not to Use / Alternatives
| Situation | Recommendation |
|---|---|
| Object has 2-3 simple fields, no validation complexity | Use a dataclass/struct literal directly; skip Builder |
| Only one concrete implementation exists and is unlikely to change | Skip Abstract Factory; construct directly or use a simple Factory Method |
| "Need one instance app-wide" | Use framework-managed singleton lifetime via DI, not a hand-rolled Singleton class |
| Cheap-to-construct, stateless object | Skip Prototype; just construct a new instance |
| Construction logic is genuinely trivial and stable | Direct instantiation; patterns add cost without benefit here |
Related Documentation
- SOLID Principles in Modern Software Design - Dependency Inversion and Open/Closed underpin why these patterns are structured the way they are.
- Clean Code Principles - naming and cohesion guidance applicable to factory/builder method names.
- DRY, KISS, and YAGNI - deciding when a pattern is worth its ceremony.
- Structural Design Patterns and Behavioral Design Patterns - patterns frequently combined with creational ones (e.g., Abstract Factory producing objects later wrapped by a Decorator).
- Clean Architecture - where factories typically live (the composition root / outermost layer).
- Unit Testing Principles - testing strategies for injected vs. globally-constructed dependencies.
- FastAPI Best Practices -
Depends()as a practical, framework-native alternative to hand-rolled Singleton/Factory code.
Glossary
- Composition Root: The place near application startup where concrete implementations are chosen and wired into abstractions.
- Functional Options: A Go idiom using variadic functions to configure a struct, replacing the need for a distinct Builder type.
- Typestate Pattern: Encoding an object's construction state in its type so that invalid operations (e.g.,
build()before required fields are set) are compile-time errors. - Family of Objects: A set of related objects (e.g., all repositories for one storage backend) that must be created consistently together, as produced by an Abstract Factory.
- Global Mutable State: State reachable and modifiable from anywhere in a program without going through an explicit interface - the core hazard of classic Singleton.