Skip to content
aipatterns.dev

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 *-- Attachment

Participants

  • 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 by build().
  • Director (DigestJob, here a function weeklyDigest): 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 null or undefined to 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

  1. List the product's parts and mark which are required, which are optional, and which rules span several fields.
  2. Make the product's constructor private or package-private (where the language allows) and its fields read-only.
  3. Write the builder with one method per part. Collection parts get add-style methods; scalar parts get setters. Return the builder for chaining.
  4. Put all cross-field validation in build(), and copy collections into the product so later builder calls can't change it.
  5. Replace call sites one at a time, running tests after each.
  6. 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 null arguments.
  • 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

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.