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:
$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.
$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
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 abuild()step. - You want different flavors of the same product. The same Builder can produce a
Queryor aCountingQueryvia differentbuild()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, oneCsvReportBuilder, both producing aReport).
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*Builderhelps; 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
thisfrom every step exceptbuild(). 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
QueryBuilderthat defaultsselectto*andlimitto 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.


