bigonotes
CS ConceptsDesign Patterns

Builder Pattern

Build complex, often immutable objects step by step with a fluent builder or a director, instead of long constructors.

7 min readLast updated Design Patterns

When an object has many fields, most of them optional, constructors become unreadable and error-prone. Builder lets you assemble the object step by step with named methods, validate it, and get back a finished (often immutable) object at the end.

Intent#

Separate the construction of a complex object from its representation, so the same construction process can create different representations. (GoF)

Analogy: a sandwich counter. You go step by step:

  1. Bread
  2. Protein
  3. Cheese
  4. Veggies
  5. Sauce
  6. Wrap

Same process, different combinations, different sandwiches.

The problem: constructor hell#

A constructor with many positional arguments is hard to read and easy to get wrong:

house = House(
    "concrete",  # foundation
    "brick",     # walls
    "tile",      # roof
    4,           # windows
    2,           # doors
    True,        # has_garage
    False,       # has_swimming_pool
    True,        # has_garden
    False,       # has_statues
    3,           # floors
    5,           # rooms
)
  • What if you only want foundation, walls and roof?
  • What does true, false, true, false mean at the call site?
  • It's easy to swap two arguments of the same type.
  • Covering the combinations needs many overloaded constructors (telescoping constructors).

Setters fix readability but leave the object mutable and possibly half-built. Builder fixes both.

Structure#

The pattern has up to four participants:

Participant Role
Product The complex object being built
Builder Interface (or class) with one method per part
Concrete builder Implements the steps and assembles the product
Director (optional) Knows a fixed sequence of steps and drives a builder

There are two common styles:

  1. Fluent builder (most common): a static inner class with chainable methods and a build() method. No director.
  2. Builder with director: a director owns the order of steps; different builders produce different representations.

Example 1: fluent builder#

Required fields go in the builder's constructor, optional ones get chainable methods, and build() validates:

from dataclasses import dataclass
from typing import Optional


# frozen=True: no attribute can change after construction (immutable)
@dataclass(frozen=True)
class Computer:
    # Required
    cpu: str
    ram: str
    # Optional
    storage: str
    gpu: Optional[str]
    cooler: str
    has_wifi: bool
    has_bluetooth: bool

    def __str__(self) -> str:
        gpu = self.gpu or "integrated"
        return (f"CPU={self.cpu} RAM={self.ram} storage={self.storage} "
                f"GPU={gpu} cooler={self.cooler} "
                f"wifi={self.has_wifi} bt={self.has_bluetooth}")

    class Builder:
        def __init__(self, cpu: str, ram: str):
            # Required fields go in the builder's constructor
            self._cpu = cpu
            self._ram = ram
            # Optional fields start with sensible defaults
            self._storage = "256GB SSD"
            self._gpu: Optional[str] = None
            self._cooler = "Stock Cooler"
            self._wifi = True
            self._bluetooth = False

        def with_storage(self, storage: str) -> "Computer.Builder":
            self._storage = storage
            return self

        def with_gpu(self, gpu: str) -> "Computer.Builder":
            self._gpu = gpu
            return self

        def with_cooler(self, cooler: str) -> "Computer.Builder":
            self._cooler = cooler
            return self

        def with_wifi(self, on: bool) -> "Computer.Builder":
            self._wifi = on
            return self

        def with_bluetooth(self, on: bool) -> "Computer.Builder":
            self._bluetooth = on
            return self

        # Validate the combination before the object exists
        def build(self) -> "Computer":
            rtx = self._gpu is not None and "RTX" in self._gpu
            if rtx and self._cooler != "Liquid Cooling":
                raise ValueError("RTX GPU requires liquid cooling")
            return Computer(self._cpu, self._ram, self._storage,
                            self._gpu, self._cooler,
                            self._wifi, self._bluetooth)


# Usage
gaming_pc = (Computer.Builder("Intel i9", "32GB")
             .with_storage("2TB NVMe")
             .with_gpu("RTX 4090")
             .with_cooler("Liquid Cooling")
             .with_bluetooth(True)
             .build())

office_pc = (Computer.Builder("Intel i5", "16GB")
             .with_storage("512GB SSD")
             .build())  # defaults for everything else
print(office_pc)

Template#

  1. Product class with read-only fields (here a frozen dataclass).
  2. A nested Builder class with the same fields, required ones in its __init__ and optional ones with defaults.
  3. Chainable setters that return self, and build() that validates and returns the product.

Example 2: a SQL query builder#

The same idea builds a SQL query string one clause at a time:

from typing import List, Optional


class SQLQuery:
    def __init__(self, table: str, columns: List[str],
                 where_clauses: List[str], limit: Optional[int]):
        # Instance attributes in __init__, never mutable class-level defaults
        self.table = table
        self.columns = columns
        self.where_clauses = where_clauses
        self.limit = limit

    def __repr__(self) -> str:
        cols = ", ".join(self.columns) if self.columns else "*"
        sql = f"SELECT {cols} FROM {self.table}"
        if self.where_clauses:
            sql += " WHERE " + " AND ".join(self.where_clauses)
        if self.limit is not None:
            sql += f" LIMIT {self.limit}"
        return sql

    class Builder:
        def __init__(self) -> None:
            # Temporary state while the caller chains methods
            self._table: Optional[str] = None
            self._columns: List[str] = []
            self._where: List[str] = []
            self._limit: Optional[int] = None

        def select(self, columns: List[str]) -> "SQLQuery.Builder":
            self._columns = columns
            return self

        def from_table(self, table: str) -> "SQLQuery.Builder":
            self._table = table
            return self

        def where(self, clause: str) -> "SQLQuery.Builder":
            self._where.append(clause)
            return self

        def limit(self, n: int) -> "SQLQuery.Builder":
            self._limit = n
            return self

        def build(self) -> "SQLQuery":
            # build() validates AND returns the finished product
            if self._table is None:
                raise ValueError("No table selected")
            return SQLQuery(self._table, self._columns,
                            list(self._where), self._limit)


query = (SQLQuery.Builder()
         .select(["id", "name"])
         .from_table("users")
         .where("active = 1")
         .limit(10)
         .build())
print(query)  # SELECT id, name FROM users WHERE active = 1 LIMIT 10

Example 3: builder with a director#

Use a director when the sequence of steps is fixed but you want different representations from it.

from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class House:
    foundation: str = ""
    structure: str = ""
    roof: str = ""
    windows: int = 0
    has_garage: bool = False
    has_pool: bool = False
    has_garden: bool = False


class HouseBuilder(ABC):
    def __init__(self) -> None:
        self._house = House()

    @abstractmethod
    def build_foundation(self) -> None: ...

    @abstractmethod
    def build_structure(self) -> None: ...

    @abstractmethod
    def build_roof(self) -> None: ...

    @abstractmethod
    def build_windows(self) -> None: ...

    @abstractmethod
    def build_garage(self) -> None: ...

    @abstractmethod
    def build_pool(self) -> None: ...

    @abstractmethod
    def build_garden(self) -> None: ...

    def get_house(self) -> House:
        return self._house


class LuxuryHouseBuilder(HouseBuilder):
    def build_foundation(self) -> None:
        self._house.foundation = "Reinforced concrete with basement"

    def build_structure(self) -> None:
        self._house.structure = "Steel frame with brick"

    def build_roof(self) -> None:
        self._house.roof = "Solar panel tiles"

    def build_windows(self) -> None:
        self._house.windows = 20

    def build_garage(self) -> None:
        self._house.has_garage = True

    def build_pool(self) -> None:
        self._house.has_pool = True

    def build_garden(self) -> None:
        self._house.has_garden = True


class SimpleHouseBuilder(HouseBuilder):
    def build_foundation(self) -> None:
        self._house.foundation = "Concrete slab"

    def build_structure(self) -> None:
        self._house.structure = "Wood frame"

    def build_roof(self) -> None:
        self._house.roof = "Asphalt shingles"

    def build_windows(self) -> None:
        self._house.windows = 6

    def build_garage(self) -> None:
        self._house.has_garage = False

    def build_pool(self) -> None:
        self._house.has_pool = False

    def build_garden(self) -> None:
        self._house.has_garden = True


# The director owns the ORDER of steps; builders own HOW each is done
class ConstructionDirector:
    def __init__(self, builder: HouseBuilder):
        self.builder = builder

    def construct_full_house(self) -> None:
        b = self.builder
        b.build_foundation()
        b.build_structure()
        b.build_roof()
        b.build_windows()
        b.build_garage()
        b.build_pool()
        b.build_garden()

    def construct_basic_house(self) -> None:
        b = self.builder
        b.build_foundation()
        b.build_structure()
        b.build_roof()
        b.build_windows()
        b.build_garden()  # skips garage and pool


# Usage
luxury = LuxuryHouseBuilder()
ConstructionDirector(luxury).construct_full_house()
mansion = luxury.get_house()
print(mansion)

Fluent builder vs director#

The two styles trade simplicity for a fixed, reusable step order:

Aspect Fluent builder Builder with director
Complexity Simple More classes
Who controls the order The client The director
Best for Many optional parameters Fixed steps, several representations
Method chaining Yes Usually no
Validation In build() In the director or builder
How common The vast majority of modern code Less common

Real-world examples#

Builders are common wherever an object is assembled from many optional parts:

  • SQLAlchemy Core builds statements step by step: select(users).where(...).limit(10).
  • HTTP client request builders, and UI dialog builders on mobile platforms.
  • Code-generated message builders (for example, Protocol Buffers in several languages).
  • SQL query builders, document builders (PDF/HTML).

When to use it#

A builder is worth it when:

  • More than 4–5 parameters, especially optional ones.
  • Many valid combinations of configuration.
  • You want immutable objects but still need flexible construction.
  • Construction needs validation across fields before the object exists.

When not to use it#

Skip it when:

  • Only 2–3 parameters, or all of them are required. A constructor is clearer.
  • Mutable objects with simple setters are fine for your use case.
  • In Python, keyword arguments with defaults (or a dataclass) often remove the need for a builder entirely.

Pitfalls#

Watch for these mistakes:

  • Duplicated fields. The builder mirrors the product's fields; keep them in sync.
  • Forgetting validation. build() is the one place to reject invalid combinations.
  • Reusing a builder. The builder is mutable even if the product isn't. Copy collections in build() so later changes to the builder don't leak into products already built.
  • Builder vs setters. Setters let anyone change the object at any time and can leave it half-initialized; a builder hands out only complete, validated objects.

Builder vs other creational patterns#

Each creational pattern answers a different question:

Pattern Question it answers
Factory Method Which type should be created? (one shot)
Builder How is this one complex object assembled, step by step?
Prototype Can I copy an existing object instead of building?
Singleton How do I guarantee one instance?

Key takeaways#

  • Builder replaces telescoping constructors with named, chainable steps.
  • Required fields go in the builder's constructor; optional ones get defaults.
  • build() validates and returns a finished, ideally immutable, object.
  • Use a director only when the step order is fixed and shared across representations.
  • Don't use it for small objects; in Python, keyword arguments often suffice.

Found a mistake? Tell me on LinkedIn ↗

Further reading#