Design patternCreational Patterns
Builder
Assemble a complex object through named, step-by-step calls and validate it once in build(), instead of a constructor with many parameters.
- Complexity
- Medium
- Seen in the wild
- High
Intent
Separate the construction of a complex object from the object itself. A builder collects the parts through small, named calls, then checks that the combination is valid and returns a finished object, usually immutable, in a single build() step.
Forces
- Many optional parts vs readable call sites:
new Email(a, b, null, null, c, [], true)hides which argument is which. - Immutable result vs piecemeal assembly: the parts arrive across loops and conditionals, but the finished object should never change.
- Validation of the whole vs validation per field: some rules span fields ("at least one recipient", "total attachment size").
- One construction process vs several representations: sometimes the same steps should produce different outputs.
Therefore
Give the object a companion builder with one method per part, and make build() the only way to get a finished, validated instance.
The builder is mutable and short-lived. The product is complete and, ideally, immutable. Call sites read as a list of named steps, and invalid combinations fail in one place. When the same sequence of steps is reused, put it in a director: a function or class that drives the builder.
Structure
Diagram source (Mermaid)
classDiagram
class EmailBuilder {
-string[] to
-string subject
-Attachment[] attachments
+addTo(address) EmailBuilder
+subject(text) EmailBuilder
+attach(file) EmailBuilder
+build() EmailMessage
}
class EmailMessage {
-string from
-string[] to
-string subject
+totalBytes() int
}
class Attachment {
-string filename
-int bytes
}
class DigestJob {
+compose(team, report) EmailMessage
}
DigestJob ..> EmailBuilder : drives
EmailBuilder ..> EmailMessage : builds
EmailMessage *-- AttachmentParticipants
- Builder (
EmailBuilder): mutable; exposes one method per part and returns itself for chaining. - Product (
EmailMessage): the finished object. Has no setters and is only created bybuild(). - Director (
DigestJob, here a functionweeklyDigest): optional; encodes a reusable sequence of builder calls. - Client: creates a builder, calls steps (directly or through a director), and calls
build().
Use it when
- A constructor has four or more parameters, several optional, and call sites pass
nullorundefinedto skip them. - Parts are collected incrementally, in loops or conditionals, before the object is usable.
- The finished object must be immutable, but constructing it in one expression is impractical.
- Validation spans several fields and must happen before the object exists.
- The same assembly steps must produce different representations (a MIME message and a plain-text preview, for example).
Do not use it when
- The parameters are independent and all known at the call site. The language's native optional arguments cover it: named or keyword parameters with defaults in Python, Kotlin, C# and Swift (
EmailMessage(to=[...], subject="...")), a typed options object in TypeScript (new EmailMessage({ to, subject })), a struct literal or functional options in Go (New(WithCC(...))). Reserve Builder for step-wise accumulation or validation that spans fields. - The object has two or three required fields. A constructor is shorter and enforces them at compile time, which a builder does not.
- The builder would mirror every field one to one with no validation and no accumulation. That is a second class doing the constructor's job.
Application grill
Inspect first: the constructor or parameter sprawl the pattern would fix, and every construction site that would have to migrate; whether a validation library (zod, class-validator, pydantic) already exists that build() should call.
Missing pieces: validation at the trust boundary: if the object's invariants are checked nowhere, put them in build(). A validation library is justified when the same rules are enforced in several places, not for one type.
Ask before you build:
- Scale: how many optional parameters are real today - an options object covers modest cases without builder ceremony?
- Performance: is the object built per element in a loop where extra allocations show?
- Reliability: which invariants must fail loudly in build(), and which may surface later?
- Budget: builder plus target roughly doubles the surface for one type; how many construction sites pay that back?
- Implementation: does build() return the plain type or a validated one, and can callers still construct the target directly?
Implementation steps
- List the product's parts and mark which are required, which are optional, and which rules span several fields.
- Make the product's constructor private or package-private (where the language allows) and its fields read-only.
- Write the builder with one method per part. Collection parts get
add-style methods; scalar parts get setters. Return the builder for chaining. - Put all cross-field validation in
build(), and copy collections into the product so later builder calls can't change it. - Replace call sites one at a time, running tests after each.
- If several call sites repeat the same sequence of steps, extract it into a director function.
Example
A reporting service sends weekly digests. Recipients come from a team list, the attachment is optional, and the mail provider rejects messages over 10 MB, so the size rule must be checked before sending.
interface Attachment {
filename: string;
bytes: number;
}
const MAX_BYTES = 10 * 1024 * 1024;
class EmailMessage {
constructor(
readonly from: string,
readonly to: readonly string[],
readonly subject: string,
readonly body: string,
readonly attachments: readonly Attachment[],
) {}
totalBytes(): number {
return this.attachments.reduce((sum, a) => sum + a.bytes, 0);
}
}
class EmailBuilder {
private readonly to: string[] = [];
private readonly attachments: Attachment[] = [];
private subjectText = "";
private bodyText = "";
constructor(private readonly from: string) {}
addTo(address: string): this {
this.to.push(address);
return this;
}
subject(text: string): this {
this.subjectText = text;
return this;
}
body(text: string): this {
this.bodyText = text;
return this;
}
attach(file: Attachment): this {
this.attachments.push(file);
return this;
}
build(): EmailMessage {
if (this.to.length === 0) throw new Error("at least one recipient is required");
if (!this.subjectText.trim()) throw new Error("subject is required");
const msg = new EmailMessage(this.from, [...this.to], this.subjectText, this.bodyText, [...this.attachments]);
if (msg.totalBytes() > MAX_BYTES) throw new Error("attachments exceed 10 MB");
return msg;
}
}
// Director: one reusable sequence of steps, including loops and conditionals.
function weeklyDigest(team: string[], reportBytes: number | null): EmailMessage {
const b = new EmailBuilder("reports@example.com").subject("Weekly usage digest").body("Numbers attached.");
team.forEach((member) => b.addTo(member));
if (reportBytes !== null) b.attach({ filename: "usage.csv", bytes: reportBytes });
return b.build();
}
const msg = weeklyDigest(["ana@example.com", "li@example.com"], 48_000);
console.log(msg.to.length, msg.totalBytes()); // 2 48000
try {
weeklyDigest([], null);
} catch (e) {
console.log((e as Error).message); // at least one recipient is required
}Trade-offs
Gains
- Call sites name each part, so there are no positional
nullarguments. - The product can be immutable even though it is assembled piece by piece.
- Cross-field validation runs once, before an invalid object can exist.
- A director captures a common recipe so call sites don't repeat it.
Costs
- A second class that must stay in sync with every field of the product.
- Required fields are checked at runtime in
build(), not by the compiler. A constructor catches a missing argument earlier. - Duplicates what named arguments, options objects or functional options already give you in many languages.
Relations
- Abstract Factory returns finished, related objects in one call. Builder assembles one object over several calls.
- Factory Method decides which class to create. Builder decides how one instance is put together. A factory method can return a preconfigured builder.
- Composite trees are a common product of builders, since nested structures are tedious to construct by hand.
- Prototype starts from a copy of an existing object. Builder starts from nothing. You can seed a builder from a prototype ("same as this message, but to a different team").
Agent checklist
- The product has many optional parts, step-wise accumulation, or cross-field rules. Otherwise use a constructor, options object or keyword arguments.
- Required parts that the compiler could enforce are constructor parameters of the builder, not optional setters.
-
build()validates the whole object and copies collections, so the product never shares mutable state with the builder. - The product has no public setters and no way to be created except through
build(), where the language allows it. - Every rewritten call site produces an object equal to the one it produced before. Run the existing tests.
See also
- Abstract FactoryCreate families of related objects through one interface so client code always gets a matching set and never names the concrete classes.
- Factory MethodLet a subclass or a supplied function decide which concrete object to create, so shared workflow code depends only on an interface.
- CompositeTreat single objects and nested groups of them through one interface, so code can walk a tree of any depth without checking node types.
- PrototypeCreate new objects by copying a configured existing instance, so callers get a ready-made variant without knowing its class or setup.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.