Factory & Builder Patterns
Factories centralize object creation behind a function or class method so callers stay decoupled from concrete types. Builders assemble complex objects step-by-step when constructors would need too many parameters or optional combinations.
Recipe
Quick-reference recipe card - copy-paste ready.
from dataclasses import dataclass
@dataclass
class HttpClient:
base_url: str
timeout: float = 30.0
retries: int = 3
def http_client_from_env(env: dict) -> HttpClient:
return HttpClient(
base_url=env["API_URL"],
timeout=float(env.get("HTTP_TIMEOUT", 30)),
)When to reach for this:
- Factory when creation logic branches on config, env, or runtime type
- Builder when objects have many optional fields or validation between steps
- classmethod constructors (
from_dict,from_url) as lightweight factories - Hiding import cycles - factory module imports concrete classes, callers do not
- Test doubles - factories accept injectable backends
Working Example
A factory picks storage backends; a builder constructs a validated report config.
from dataclasses import dataclass, field
from typing import Protocol
class Storage(Protocol):
def save(self, key: str, data: bytes) -> None: ...
@dataclass
class MemoryStorage:
_data: dict[str, bytes] = field(default_factory=dict)
def save(self, key: str, data: bytes) -> None:
self._data[key] = data
@dataclass
class FileStorage:
root: str
def save(self, key: str, data: bytes) -> None:
print(f"write {key} to {self.root}")
def storage_factory(kind: str, **kwargs) -> Storage:
if kind == "memory":
return MemoryStorage()
if kind == "file":
return FileStorage(root=kwargs["root"])
raise ValueError(f"unknown storage: {kind}")
@dataclass
class ReportConfig:
title: str
columns: list[str]
page_size: int = 50
class ReportConfigBuilder:
def __init__(self, title: str) -> None:
self._title = title
self._columns: list[str] = []
self._page_size = 50
def add_column(self, name: str) -> "ReportConfigBuilder":
self._columns.append(name)
return self
def page_size(self, size: int) -> "ReportConfigBuilder":
if size < 1:
raise ValueError("page_size must be positive")
self._page_size = size
return self
def build(self) -> ReportConfig:
if not self._columns:
raise ValueError("at least one column required")
return ReportConfig(self._title, list(self._columns), self._page_size)
store = storage_factory("memory")
store.save("report.pdf", b"%PDF")
cfg = (
ReportConfigBuilder("Sales")
.add_column("sku")
.add_column("revenue")
.page_size(100)
.build()
)
print(cfg)What this demonstrates:
- Factory encapsulates branching construction
- Protocol types keep callers storage-agnostic
- Builder methods return
selffor fluent chaining build()validates invariants before returning the product
Deep Dive
How It Works
- Factory: one function maps inputs to a concrete instance
- Builder: accumulates state, validates at
build(), avoids telescoping constructors @dataclass+field(default_factory=...)often replaces builders for simple cases- Classmethods (
from_env,parse) are idiomatic Python factories - Dependency injection passes factories instead of instances for late binding
Factory vs Builder
| Pattern | Solves | Python idiom |
|---|---|---|
| Factory | Which class to instantiate | function or @classmethod |
| Builder | Many optional assembly steps | fluent builder or nested dataclass |
Python Notes
@dataclass
class Settings:
debug: bool = False
@classmethod
def from_env(cls, env: dict) -> "Settings":
return cls(debug=env.get("DEBUG") == "1")Gotchas
- God factory - one function with 20 elif branches. Fix: registry dict or subclass factories per domain.
- Builder without validation - invalid partial state escapes. Fix: validate only in
build(), keep builder mutable internally. - Over-engineering simple dataclasses - three optional fields do not need a builder. Fix: use defaults and
dataclass. - Factory hiding side effects - creating a client should not connect to network. Fix: separate
createfromconnect. - Mutable builder reuse - reusing the same builder after
build()causes cross-talk. Fix: document single-use or clone onbuild().
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
@dataclass defaults | Few optional fields | Complex cross-field validation |
| Pydantic models | Validation + parsing from env/JSON | Zero-dep scripts |
**kwargs constructor | Internal library only | Public API needs stable signatures |
| Abstract factory OOP | Families of related products | Single product line |
FAQs
Do I need a Builder class in Python?
Often no. dataclass with defaults, model_validate, or a single factory function covers many cases. Builders help when assembly is multi-step and order matters.
Where should factories live?
In the module that owns the product types, or a dedicated factories.py when creation crosses subpackages.
How do factories help testing?
Pass a factory callable that returns fakes/mocks. Production uses real factory; tests inject lambda: FakeStorage().
Are classmethods factories?
Yes. from_dict, from_url, and parse are standard Python factory methods.
Can builders be dataclasses?
Use a mutable builder class or dataclass with frozen=False internally; freeze the product with frozen=True.
What about __init_subclass__ for registration?
Plugin frameworks register subclasses automatically - a form of factory discovery.
How does Pydantic fit?
Model.model_validate(data) is a validated factory from dicts/JSON - prefer it for config objects.
Should build() return a copy?
If the product is mutable, returning a deep copy prevents callers from mutating cached builder state.
Factory vs dependency injection container?
DI containers call factories/providers. Simple apps use plain functions without a framework.
How do I type a factory?
Callable[[], Storage] or Protocol with a create() method. Generics when product type varies.
Related
- Dependency Injection in Python - wiring factories
- The Strategy & Dispatch Patterns - factory + strategy
- Null Object & Sentinels - default product instances
- Pythonic Patterns Basics - section overview
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.