Creational Design Patterns: Factory, Builder and the Singleton Debate
ProgrammingLesson 1 of 47 min read
What you'll learn
- Explain what problem creational patterns solve and recognise it in existing code
- Write a factory that selects an implementation without a growing if-else chain
- Use a builder to assemble an object with many optional parts and cross-field validation
- Argue for and against a singleton, and name concrete alternatives
On this page
Invoices arrive as CSV from one client and JSON from another. Six months in, the string if name.endswith(".csv") ... elif name.endswith(".json") appears in the upload handler, the retry job, the audit script and two tests. Then a third format shows up and you have to remember all five places. The code has a *creation* problem: every caller knows which concrete thing to build, and that knowledge is scattered.
What creational patterns are for
A creational pattern moves the decision of *what to create and how* out of the code that uses the result. The user of an object should depend on what the object can do, not on the class that was chosen or the sequence of steps that assembled it. The Gang of Four catalogue lists five (factory method, abstract factory, builder, prototype, singleton); in everyday code three of them matter most.
| Pattern | Problem it answers | Typical sign you need it |
|---|---|---|
| Factory (simple factory or factory method) | Which implementation should I create for this input? | The same if type == ... chain in several places |
| Builder | How do I assemble a complex object step by step and validate it as a whole? | Constructors with many optional parameters or rules that span fields |
| Singleton | How do I guarantee exactly one instance and give global access to it? | A shared resource like a hardware port; often misapplied to configuration |
| Abstract factory | How do I create a family of related objects that must match? | Light and dark theme widgets, or a database driver family |
| Prototype | How do I make a new object by copying an existing one? | Creation is expensive and variants differ slightly |
Factory: callers ask for a capability, not a class
The factory below turns a filename into a parser. Callers write make_parser(name)(text) and never mention csv or json. The extension-to-parser mapping lives in one dictionary, and a small decorator registers each parser, so adding a format means writing one function with one decorator line. No existing code is edited. A factory that hides an if chain inside is only moving the problem; a registry removes it.
import csv
import io
import json
import os
PARSERS = {}
def parser_for(extension):
"""Decorator that registers a parser function under a file extension."""
def register(func):
PARSERS[extension] = func
return func
return register
@parser_for(".csv")
def parse_csv(text):
return [dict(row) for row in csv.DictReader(io.StringIO(text))]
@parser_for(".json")
def parse_json(text):
return json.loads(text)
def make_parser(filename):
"""The factory: callers say which file, not which parser class."""
extension = os.path.splitext(filename)[1].lower()
try:
return PARSERS[extension]
except KeyError:
known = ", ".join(sorted(PARSERS))
raise ValueError(f"no parser for {extension!r} (known: {known})") from None
print(make_parser("march.CSV")("invoice_id,amount\nINV-1,250\nINV-2,90"))
print(make_parser("batch.json")('[{"invoice_id": "INV-3", "amount": 40}]'))
try:
make_parser("scan.pdf")
except ValueError as err:
print("ValueError:", err)Output
[{'invoice_id': 'INV-1', 'amount': '250'}, {'invoice_id': 'INV-2', 'amount': '90'}]
[{'invoice_id': 'INV-3', 'amount': 40}]
ValueError: no parser for '.pdf' (known: .csv, .json)Note the failure mode: an unknown extension raises a clear ValueError that lists what is supported, instead of a None that crashes three calls later. Strictly speaking, a plain function like make_parser is a *simple factory*, which is not one of the Gang of Four patterns. The GoF factory method is the object-oriented version in which a base class declares a creation method and subclasses decide what to return. Python's first-class functions and Java's lambdas often make the simple version enough.
Builder: when construction has steps and rules
Imagine a job-search request with keywords, a city, a remote flag and a salary band. Some combinations are illegal: remote-only with a city, or a minimum above the maximum. Cramming five optional parameters into a constructor produces calls that are impossible to read, and validation scattered across setters can never see the whole picture. A builder collects the parts through readable, chainable calls and validates everything in one build() step that returns an immutable object.
from dataclasses import dataclass
@dataclass(frozen=True)
class JobSearch:
keywords: tuple
city: str | None
remote_only: bool
min_salary: int | None
max_salary: int | None
def to_query(self):
parts = [f"q={'+'.join(self.keywords)}"]
if self.city:
parts.append(f"city={self.city}")
if self.remote_only:
parts.append("remote=1")
if self.min_salary is not None:
parts.append(f"min={self.min_salary}")
if self.max_salary is not None:
parts.append(f"max={self.max_salary}")
return "&".join(parts)
class JobSearchBuilder:
def __init__(self):
self._keywords = []
self._city = None
self._remote_only = False
self._min_salary = None
self._max_salary = None
def keyword(self, word):
self._keywords.append(word)
return self # returning self makes calls chainable
def city(self, name):
self._city = name
return self
def remote_only(self):
self._remote_only = True
return self
def salary_between(self, low, high):
self._min_salary, self._max_salary = low, high
return self
def build(self):
# all cross-field validation happens in one place, before any object exists
if not self._keywords:
raise ValueError("at least one keyword is required")
if self._remote_only and self._city:
raise ValueError("remote_only and city cannot be combined")
if (self._min_salary is not None
and self._max_salary is not None
and self._min_salary > self._max_salary):
raise ValueError("min_salary is above max_salary")
return JobSearch(tuple(self._keywords), self._city, self._remote_only,
self._min_salary, self._max_salary)
search = (JobSearchBuilder()
.keyword("python").keyword("backend")
.remote_only()
.salary_between(900_000, 1_800_000)
.build())
print(search.to_query())
try:
JobSearchBuilder().keyword("java").city("Pune").remote_only().build()
except ValueError as err:
print("ValueError:", err)Output
q=python+backend&remote=1&min=900000&max=1800000 ValueError: remote_only and city cannot be combined
Two design choices matter. The builder is mutable and temporary, while the product, JobSearch, is a frozen dataclass: once built it cannot be changed. And every rule that involves more than one field is checked in build(), so an invalid JobSearch can never exist. Python often does not need this: when fields are independent, a dataclass with keyword arguments and defaults is shorter and clearer. Builders earn their keep in Java and C++, where the lack of keyword arguments leads to the *telescoping constructor* problem, and in Python when assembly is incremental, for example from query-string pieces.
Singleton: why people argue about it
A singleton ensures a class has exactly one instance and provides a global way to reach it. There are real cases: a connection to a single hardware device, or a process-wide registry. But it is the most criticised pattern in the book, and the arguments are practical, not stylistic.
- Hidden dependencies. Any function can call
Settings()and silently depend on it, so the function's signature no longer says what it needs. - Shared mutable state between tests. One test changes the instance and the next test inherits the change.
- Thread-safety is on you. The
if cls._instance is Nonecheck below is a race in threaded code, and Java's classic fix, double-checked locking, only works if the field isvolatile. - "One" is rarely true for long. One per process becomes one per tenant, one per test, or one per class loader (Java) or worker process (Python).
class Settings:
_instance = None
def __new__(cls):
if cls._instance is None: # not thread-safe: two threads can both see None
cls._instance = super().__new__(cls)
cls._instance.debug = False
cls._instance.currency = "INR"
return cls._instance
def test_debug_logging():
Settings().debug = True # this test flips a global switch
assert Settings().debug is True
def test_defaults_are_quiet():
assert Settings().debug is False, "debug leaked in from another test"
for test in (test_debug_logging, test_defaults_are_quiet):
try:
test()
print("PASS", test.__name__)
except AssertionError as err:
print("FAIL", test.__name__, "-", err)
print(Settings() is Settings())Output
PASS test_debug_logging FAIL test_defaults_are_quiet - debug leaked in from another test True
The second test fails depending only on execution order, and it fails in a way that points nowhere near the cause. This kind of order-dependent flakiness is the most common real-world cost. The alternative below is dull on purpose: build one immutable Settings at start-up and pass it to whoever needs it. Production still has exactly one, but nothing forces the rest of the code to know that.
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
debug: bool = False
currency: str = "INR"
class PriceFormatter:
def __init__(self, settings: Settings): # dependency passed in, not fetched globally
self.settings = settings
def format(self, amount):
return f"{self.settings.currency} {amount:,.2f}"
# One instance in production, created once at start-up and handed to whoever needs it
app_settings = Settings()
print(PriceFormatter(app_settings).format(1250000))
# A test creates exactly the instance it wants; nothing leaks between tests
print(PriceFormatter(Settings(currency="USD")).format(1250000))Output
INR 1,250,000.00 USD 1,250,000.00
Common mistakes
- A factory with a growing `if` chain. It does not remove the coupling, it relocates it. Use a registry or a dictionary so new types do not edit existing code.
- Returning inconsistent types from a factory. If
make_parsersometimes returns a function and sometimes a class, every caller needs a check. Define the shared interface and keep to it. - Silent `None` for unknown input. The failure surfaces later as
TypeError: 'NoneType' object is not callable. Raise an error that names the bad input. - A builder that forgets to validate in `build()`. Illegal objects are then possible. Put cross-field rules in exactly one place.
- Reusing a builder after `build()`. If
build()hands out the builder's own internal list, a later call mutates the earlier product. Copy, astuple(self._keywords)does. - A builder for three simple fields. More code than a constructor call, with no benefit.
- Singleton as a global variable with a pattern name. Mutable global state is still mutable global state, which is the source of the order-dependent test failure above.
How this shows up in real projects and interviews
You use factories constantly without naming them: logging.getLogger, DriverManager.getConnection, a framework deciding which serialiser to use for a content type. Builders show up in HTTP clients, query objects and test data (UserBuilder().admin().build()). Singletons mostly appear as warnings in code review, and the productive response is to ask "can this be injected?", not to ban the word.
In interviews, expect to implement a thread-safe singleton and then defend whether you should. A strong answer writes it, names the testing and hidden-dependency costs, and offers dependency injection as the alternative. The same discipline of keeping construction apart from use underlies SOLID's dependency inversion. Next, structural patterns deal with how existing objects fit together.
Try it yourself
Extend parser_factory.py with a .tsv parser registered by one decorator line, then change make_parser to accept an optional override argument naming an extension to use regardless of the filename (for files saved with the wrong extension). Add a JobSearchBuilder rule that rejects more than five keywords, and show with try/except that each new rule fires.
Show a hint
A TSV parser can reuse csv.DictReader with delimiter="\t". Keep the registry lookup in a single helper used by both the filename path and the override path so you do not duplicate the error message.
Key takeaways
- Creational patterns keep knowledge of which concrete thing to build, and how, out of the code that uses it.
- A registry-based factory lets you add types without editing existing code; fail loudly on unknown input.
- A builder pays off when construction is incremental and rules span several fields; validate everything in
build()and return an immutable object. - Singleton's costs are hidden dependencies, shared mutable state and thread-safety; prefer creating one object and passing it in.
- Reach for a pattern only when you can point to the duplicated or tangled code it removes.
Test yourself
A trick from this lesson's ride: Same if/elif ladder in many places? Climb it once, inside a factory. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between factory and builder pattern?
A factory decides which type of object to create and returns it in one call, hiding the concrete class. A builder assembles one complex object step by step and validates it at the end. Use a factory to choose an implementation and a builder to configure one.
Why is the singleton pattern considered an anti-pattern?
It introduces global state that functions can use without declaring it, makes tests depend on each other because the instance persists, and needs extra care for thread safety. Many teams pass a single shared object explicitly using dependency injection instead.
Do I need design patterns in Python?
You use the ideas, but Python's functions, modules and decorators often replace the heavy class-based versions. A dictionary of functions can be a factory, a module is already a singleton, and keyword arguments cover many builder cases. Use a full pattern when it removes real duplication.