Skip to main content
PProfileMatch

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

  1. What creational patterns are for
  2. Factory: callers ask for a capability, not a class
  3. Builder: when construction has steps and rules
  4. Singleton: why people argue about it
  5. Common mistakes
  6. How this shows up in real projects and interviews

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.

PatternProblem it answersTypical 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
BuilderHow 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
SingletonHow do I guarantee exactly one instance and give global access to it?A shared resource like a hardware port; often misapplied to configuration
Abstract factoryHow do I create a family of related objects that must match?Light and dark theme widgets, or a database driver family
PrototypeHow 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.

parser_factory.py
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.

job_search_builder.py
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 None check below is a race in threaded code, and Java's classic fix, double-checked locking, only works if the field is volatile.
  • "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).
singleton_trouble.py
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.

singleton_alternatives.py
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_parser sometimes 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, as tuple(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.

Ride this lesson

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.