Intent

Separate the construction of a complex object from its final representation, so the same step-by-step construction process can produce different objects — and so the call site stays readable when the object has many optional parts.

The Problem

You're modelling a SQL query inside the application. The first version has a constructor:

PHP
$query = new Query('users', ['id', 'name'], ['active' => true], 'name', 10, 0);

What's the fourth argument? What does the 0 at the end mean? Did anyone notice you passed null for the join clause? And now the team needs groupBy, having, union, parameter binding — the constructor signature grows past readability and tests get harder to write because you have to fill every parameter even when you don't care.

The standard escape route is the telescoping constructor — overload after overload, each adding one optional argument. The other escape is setters everywhere — but then nothing stops you from running a half-built query, and there's no obvious moment when "you're done." Object literals (in JS, kwargs in Python) are nicer, but they don't enforce required fields, can't validate cross-field constraints, and don't give you a meaningful single object to pass around.

The shape of the smell: an object with many optional parts, some of which depend on each other, and no good moment to validate the whole thing.

The Solution

Builder says: introduce a separate object whose only job is to construct the target step-by-step. Each method on the Builder configures one piece and returns the Builder itself, so calls chain. A final build() method validates the whole thing and returns the finished object.

PHP
$query = (new QueryBuilder())
    ->table('users')
    ->select('id', 'name')
    ->where('active', true)
    ->orderBy('name')
    ->limit(10)
    ->build();

Reads like English. Each step is independent. The validation moment is explicit: build() is where "this query is incomplete" can become an error. And because the Builder is a separate class, you can give it sensible defaults, multiple build() flavors, or even subclass it to produce variants — without making Query itself messy.

Real-World Analogy

Ordering a custom pizza. You don't try to shout the entire order in one breath — "PIZZA WITH MOZZARELLA, MUSHROOMS, OLIVES, NO ANCHOVIES, EXTRA CHEESE, HALF-CUT, GLUTEN-FREE BASE." That's a constructor with twelve parameters in a coat. Instead, you build up the order one decision at a time: "I'll have a large. Mozzarella. Mushrooms. Olives. No anchovies. Extra cheese. Halve it. Gluten-free base. OK, that's it."

Each step is a separate decision that the kitchen can hold in their head. When you say "that's it" — that's the moment they validate (do we have gluten-free dough today?) and start cooking.

Structure

Builder pattern: a QueryBuilder configures a Query through chained methods, with a final build() that returns the finished Query.
Builder: each step returns the builder; build() returns the finished product.

Three (sometimes four) roles you'll see in every Builder implementation:

  • Product — the final object that gets constructed. Often immutable. Here: Query.
  • Builder — the object that walks you through construction. Holds the partial state; exposes one method per configuration step; ends with build(). Here: QueryBuilder.
  • Client — the caller that drives the builder.
  • (Optional) Director — a separate object that knows common construction sequences and orchestrates the Builder for them. Skip it unless you have several recurring recipes.

The defining trick: each configuration method returns the Builder itself, so calls chain naturally — and build() is the only thing that returns the Product.

Code Examples

Here's a small SQL query builder in five languages. Watch for the chaining: each method returns the builder, and build() is the one place where validation and final assembly happen.

class Query {
  constructor(
    readonly table: string,
    readonly columns: string[],
    readonly wheres: Record<string, unknown>,
    readonly orderBy: string | null,
    readonly limit: number | null,
  ) {}
}

export class QueryBuilder {
  private _table = "";
  private _columns: string[] = ["*"];
  private _wheres: Record<string, unknown> = {};
  private _orderBy: string | null = null;
  private _limit: number | null = null;

  table(name: string): this { this._table = name; return this; }
  select(...cols: string[]): this { this._columns = cols; return this; }
  where(col: string, value: unknown): this { this._wheres[col] = value; return this; }
  orderBy(col: string): this { this._orderBy = col; return this; }
  limit(n: number): this { this._limit = n; return this; }

  build(): Query {
    if (!this._table) throw new Error("Query requires a table.");
    return new Query(this._table, this._columns, this._wheres, this._orderBy, this._limit);
  }
}

// Use:
const q = new QueryBuilder()
  .table("users")
  .select("id", "name")
  .where("active", true)
  .orderBy("name")
  .limit(10)
  .build();
from dataclasses import dataclass, field

@dataclass(frozen=True)
class Query:
    table:    str
    columns:  list
    wheres:   dict
    order_by: str | None
    limit:    int | None

class QueryBuilder:
    def __init__(self):
        self._table = ""
        self._columns = ["*"]
        self._wheres = {}
        self._order_by = None
        self._limit = None

    def table(self, name):
        self._table = name; return self
    def select(self, *cols):
        self._columns = list(cols); return self
    def where(self, col, value):
        self._wheres[col] = value; return self
    def order_by(self, col):
        self._order_by = col; return self
    def limit(self, n):
        self._limit = n; return self

    def build(self):
        if not self._table:
            raise ValueError("Query requires a table.")
        return Query(self._table, self._columns, self._wheres, self._order_by, self._limit)

# Use:
q = (QueryBuilder()
     .table("users")
     .select("id", "name")
     .where("active", True)
     .order_by("name")
     .limit(10)
     .build())
public final class Query {
    public final String table;
    public final List<String> columns;
    public final Map<String, Object> wheres;
    public final String orderBy;
    public final Integer limit;

    Query(String table, List<String> columns, Map<String, Object> wheres,
          String orderBy, Integer limit) {
        this.table = table;
        this.columns = columns;
        this.wheres = wheres;
        this.orderBy = orderBy;
        this.limit = limit;
    }
}

public final class QueryBuilder {
    private String table = "";
    private List<String> columns = List.of("*");
    private final Map<String, Object> wheres = new HashMap<>();
    private String orderBy;
    private Integer limit;

    public QueryBuilder table(String name)             { this.table = name; return this; }
    public QueryBuilder select(String... cols)         { this.columns = List.of(cols); return this; }
    public QueryBuilder where(String col, Object val)  { this.wheres.put(col, val); return this; }
    public QueryBuilder orderBy(String col)            { this.orderBy = col; return this; }
    public QueryBuilder limit(int n)                   { this.limit = n; return this; }

    public Query build() {
        if (table.isEmpty()) throw new IllegalStateException("Query requires a table.");
        return new Query(table, columns, wheres, orderBy, limit);
    }
}

// Use:
Query q = new QueryBuilder()
    .table("users")
    .select("id", "name")
    .where("active", true)
    .orderBy("name")
    .limit(10)
    .build();
<?php

namespace App\Db;

final class Query
{
    public function __construct(
        public readonly string $table,
        public readonly array  $columns,
        public readonly array  $wheres,
        public readonly ?string $orderBy,
        public readonly ?int    $limit,
    ) {}
}

final class QueryBuilder
{
    private string  $table = '';
    private array   $columns = ['*'];
    private array   $wheres = [];
    private ?string $orderBy = null;
    private ?int    $limit = null;

    public function table(string $name): self        { $this->table = $name; return $this; }
    public function select(string ...$cols): self    { $this->columns = $cols; return $this; }
    public function where(string $c, mixed $v): self { $this->wheres[$c] = $v; return $this; }
    public function orderBy(string $col): self       { $this->orderBy = $col; return $this; }
    public function limit(int $n): self              { $this->limit = $n; return $this; }

    public function build(): Query
    {
        if ($this->table === '') {
            throw new \DomainException('Query requires a table.');
        }
        return new Query($this->table, $this->columns, $this->wheres, $this->orderBy, $this->limit);
    }
}

// Use:
$q = (new QueryBuilder())
    ->table('users')
    ->select('id', 'name')
    ->where('active', true)
    ->orderBy('name')
    ->limit(10)
    ->build();
package db

import "errors"

type Query struct {
    Table   string
    Columns []string
    Wheres  map[string]any
    OrderBy string
    Limit   int
}

type QueryBuilder struct {
    q Query
}

func NewQueryBuilder() *QueryBuilder {
    return &QueryBuilder{q: Query{Columns: []string{"*"}, Wheres: map[string]any{}}}
}

func (b *QueryBuilder) Table(name string) *QueryBuilder       { b.q.Table = name; return b }
func (b *QueryBuilder) Select(cols ...string) *QueryBuilder   { b.q.Columns = cols; return b }
func (b *QueryBuilder) Where(col string, v any) *QueryBuilder { b.q.Wheres[col] = v; return b }
func (b *QueryBuilder) OrderBy(col string) *QueryBuilder      { b.q.OrderBy = col; return b }
func (b *QueryBuilder) Limit(n int) *QueryBuilder             { b.q.Limit = n; return b }

func (b *QueryBuilder) Build() (Query, error) {
    if b.q.Table == "" {
        return Query{}, errors.New("query requires a table")
    }
    return b.q, nil
}

// Use:
// q, err := NewQueryBuilder().
//     Table("users").
//     Select("id", "name").
//     Where("active", true).
//     OrderBy("name").
//     Limit(10).
//     Build()

The shape is identical across languages: the builder accumulates partial state, each step returns this (or *QueryBuilder in Go's case), and build() is the one chokepoint where the partial becomes whole — and where the validation lives.

When to Use It

Reach for Builder when you can answer "yes" to any of these:

  • The constructor signature has crossed five parameters. Especially if half are optional. The next person who reads the call site shouldn't need to count commas.
  • Construction has multiple legal orderings. SQL queries, HTTP requests, deep configs — the user can specify pieces in whatever order makes sense to them, not in your prescribed sequence.
  • Validation of the whole object only makes sense once everything's set. Cross-field invariants ("if you set groupBy, you need at least one aggregate column") can't be enforced piece by piece — they want a build() step.
  • You want different flavors of the same product. The same Builder can produce a Query or a CountingQuery via different build() methods, or different subclasses can override one step.
  • Required vs. optional matters for readability. The chained API makes "this part is essential" visible (it's the first call) versus "this part is a nice extra" (it's a later, named call).

If your object has three fields and two of them are required, just use a constructor.

Pros and Cons

Pros

  • The call site reads like a sentence, regardless of how complex the object is.
  • Optional parts stay genuinely optional — no nulls in the constructor, no "magic value" sentinels.
  • Validation has one home (build()) instead of being scattered across setters.
  • The Product can be immutable — you assemble it through the Builder and the result has no setters.
  • Different builders can produce different representations (one JsonReportBuilder, one CsvReportBuilder, both producing a Report).

Cons

  • More code than a constructor — two classes minimum (Builder + Product).
  • The "required vs. optional" distinction is enforced at runtime, not by the type system. (Some languages have staged builders or typed builders to fix this — at significant complexity cost.)
  • Easy to forget to call build() and accidentally pass the Builder around. Naming the variable *Builder helps; type checkers help more.
  • The Director role is often introduced too early. Skip it unless you genuinely have multiple recurring recipes.

Pro Tips

  • Make the Product immutable. Once build() returns it, no one should be able to mutate it. The Builder is a workshop; the Product is the finished thing. Mixing the two roles is the most common Builder mistake.
  • Validate inside build(), not inside each setter. Half-built state is fine; only the final object needs to be valid. Validating in setters forces awkward construction orders.
  • Return this from every step except build(). That's the one method that returns the Product. Mixing this up breaks the chain.
  • Skip the Director until you need it. A Director is useful when you have several common recipes (Director::weeklyReport(), Director::monthlyReport()). For most uses, the Client is the Director — no separate class needed.
  • Consider providing sensible defaults. A QueryBuilder that defaults select to * and limit to none lets simple queries stay short.

Relations with Other Patterns

  • Factory Method also creates objects, but in one step. Builder is for objects that need many steps. They often work together: a Factory Method picks which Builder to instantiate based on input.
  • Abstract Factory creates families of related products in one call; Builder constructs one product through many calls. Different problems.
  • Composite is often what Builders produce: tree-shaped objects (HTML documents, AST nodes, UI hierarchies) lend themselves naturally to step-by-step construction.
  • Prototype is the alternative when you have a common base and want variations: clone the prototype, tweak the differences. Builder makes more sense when each construction is distinctly built up rather than copied-and-modified.

Final Tips

The first Builder I shipped was an HTTP request builder for an SDK we were writing. Customers were trying to construct a request with eight optional headers, a body, optional auth, optional retries, and an optional timeout — and our constructor was a mess of nulls. The Builder turned every customer's first-day-with-the-SDK code from a guessing game into a fluent chain that read like the docs.

The pattern earns its keep when the call site is the audience. Constructors serve the implementor; Builders serve the caller. Reach for Builder when the people writing new X(...) aren't the same people who designed the class — and want the construction to read as smoothly as the API does.