Back to blog

Builder Pattern Explained

oopcreational-patternsdesign-patternstypescriptpythonjava
Builder Pattern Explained

Introduction

Imagine you need to construct a complex object — a User with 10 optional fields, an HTTP request with headers, query params, a body, and timeout settings, or a SQL query with joins, where clauses, and ordering.

How do you create such objects without writing monstrous constructors or polluting your code with a sea of null arguments?

The Builder Pattern solves exactly this problem. It separates the construction of a complex object from its representation, allowing the same construction process to produce different results.

What You'll Learn

✅ Understand the problem Builder pattern solves
✅ Implement Builder with fluent interface (method chaining)
✅ Use the optional Director component
✅ Apply Builder in TypeScript, Python, and Java
✅ Recognize real-world Builder patterns (query builders, HTTP clients)
✅ Know when to use Builder vs other creational patterns

Prerequisites


The Problem: Telescoping Constructors

Consider a User class with many optional fields:

// ❌ Telescoping constructor anti-pattern
class User {
  constructor(
    name: string,
    email: string,
    age?: number,
    phone?: string,
    address?: string,
    city?: string,
    country?: string,
    zipCode?: string,
    profilePicture?: string,
    bio?: string
  ) { ... }
}
 
// Calling it is a nightmare
const user1 = new User("Alice", "alice@example.com");
const user2 = new User("Bob", "bob@example.com", 30, null, null, null, null, null, null, "Developer");
//                                                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ what are these?

Problems with this approach:

  1. Unreadable call sites — what does the 5th null mean?
  2. Order-dependent — easy to swap arguments by mistake
  3. Combinatorial explosion — 10 optional fields = many overloaded constructors
  4. Hard to extend — adding a field breaks all existing call sites

The Object Literal Workaround (and its limits)

// Slightly better — named fields
const user = new User({
  name: "Alice",
  email: "alice@example.com",
  bio: "Developer"
});

This works for simple cases, but loses validation (you can't validate step-by-step), immutability (the config object is mutable), and fluent readability (you can't chain calls to express a pipeline).

The Builder pattern solves all of these.


The Builder Pattern

Intent

Separate the construction of a complex object from its representation so that the same construction process can create different representations.

Structure

Key Components

ComponentRole
Builder (interface)Declares construction steps
ConcreteBuilderImplements construction steps, holds partial result
ProductThe complex object being built
Director (optional)Orchestrates building steps in a specific order

Implementation

TypeScript

// ✅ Product - the complex object
class User {
  private constructor(
    public readonly name: string,
    public readonly email: string,
    public readonly age?: number,
    public readonly phone?: string,
    public readonly address?: string,
    public readonly bio?: string
  ) {}
 
  // Static method to get a builder
  static builder(): UserBuilder {
    return new UserBuilder();
  }
 
  toString(): string {
    return `User(name=${this.name}, email=${this.email}, age=${this.age ?? 'N/A'})`;
  }
}
 
// ✅ Builder - fluent interface with method chaining
class UserBuilder {
  private _name!: string;
  private _email!: string;
  private _age?: number;
  private _phone?: string;
  private _address?: string;
  private _bio?: string;
 
  setName(name: string): UserBuilder {
    this._name = name;
    return this; // Enable chaining
  }
 
  setEmail(email: string): UserBuilder {
    this._email = email;
    return this;
  }
 
  setAge(age: number): UserBuilder {
    if (age < 0 || age > 150) {
      throw new Error(`Invalid age: ${age}`);
    }
    this._age = age;
    return this;
  }
 
  setPhone(phone: string): UserBuilder {
    this._phone = phone;
    return this;
  }
 
  setAddress(address: string): UserBuilder {
    this._address = address;
    return this;
  }
 
  setBio(bio: string): UserBuilder {
    this._bio = bio;
    return this;
  }
 
  build(): User {
    // Validate required fields
    if (!this._name) throw new Error("Name is required");
    if (!this._email) throw new Error("Email is required");
 
    // Private constructor - only builder can create User
    return new (User as any)(
      this._name,
      this._email,
      this._age,
      this._phone,
      this._address,
      this._bio
    );
  }
}
 
// ✅ Usage - fluent, readable, self-documenting
const minimalUser = User.builder()
  .setName("Alice")
  .setEmail("alice@example.com")
  .build();
 
const fullUser = User.builder()
  .setName("Bob")
  .setEmail("bob@example.com")
  .setAge(30)
  .setPhone("+1-555-0100")
  .setAddress("123 Main St, New York")
  .setBio("Full-stack developer with 5 years experience")
  .build();
 
console.log(minimalUser.toString()); // User(name=Alice, email=alice@example.com, age=N/A)
console.log(fullUser.name); // Bob

The Director (Optional)

The Director encapsulates common construction recipes:

// ✅ Director - optional component for preset configurations
class UserDirector {
  private builder: UserBuilder;
 
  constructor(builder: UserBuilder) {
    this.builder = builder;
  }
 
  // Build a guest user with minimal info
  buildGuestUser(name: string): User {
    return this.builder
      .setName(name)
      .setEmail(`${name.toLowerCase().replace(' ', '.')}@guest.temp`)
      .build();
  }
 
  // Build a full admin user
  buildAdminUser(name: string, email: string): User {
    return this.builder
      .setName(name)
      .setEmail(email)
      .setAge(25)
      .setBio("System Administrator")
      .build();
  }
}
 
const director = new UserDirector(User.builder());
const guest = director.buildGuestUser("Guest User");
const admin = director.buildAdminUser("Admin", "admin@example.com");

Python

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
 
 
@dataclass
class User:
    """Product - the complex object being constructed."""
    name: str
    email: str
    age: Optional[int] = None
    phone: Optional[str] = None
    address: Optional[str] = None
    bio: Optional[str] = None
 
    def __repr__(self) -> str:
        return f"User(name={self.name!r}, email={self.email!r}, age={self.age!r})"
 
 
class UserBuilder:
    """Builder - constructs User objects step by step."""
 
    def __init__(self) -> None:
        self._name: Optional[str] = None
        self._email: Optional[str] = None
        self._age: Optional[int] = None
        self._phone: Optional[str] = None
        self._address: Optional[str] = None
        self._bio: Optional[str] = None
 
    def set_name(self, name: str) -> UserBuilder:
        self._name = name
        return self  # Enable chaining
 
    def set_email(self, email: str) -> UserBuilder:
        self._email = email
        return self
 
    def set_age(self, age: int) -> UserBuilder:
        if not 0 <= age <= 150:
            raise ValueError(f"Invalid age: {age}")
        self._age = age
        return self
 
    def set_phone(self, phone: str) -> UserBuilder:
        self._phone = phone
        return self
 
    def set_address(self, address: str) -> UserBuilder:
        self._address = address
        return self
 
    def set_bio(self, bio: str) -> UserBuilder:
        self._bio = bio
        return self
 
    def build(self) -> User:
        if not self._name:
            raise ValueError("Name is required")
        if not self._email:
            raise ValueError("Email is required")
        return User(
            name=self._name,
            email=self._email,
            age=self._age,
            phone=self._phone,
            address=self._address,
            bio=self._bio,
        )
 
 
# Usage - fluent and readable
minimal_user = (
    UserBuilder()
    .set_name("Alice")
    .set_email("alice@example.com")
    .build()
)
 
full_user = (
    UserBuilder()
    .set_name("Bob")
    .set_email("bob@example.com")
    .set_age(30)
    .set_phone("+1-555-0100")
    .set_address("123 Main St, New York")
    .set_bio("Full-stack developer")
    .build()
)
 
print(minimal_user)  # User(name='Alice', email='alice@example.com', age=None)
print(full_user.name)  # Bob

Java

public class User {
    // Immutable fields - set only via builder
    private final String name;
    private final String email;
    private final Integer age;
    private final String phone;
    private final String address;
    private final String bio;
 
    // Private constructor - only Builder can create User
    private User(Builder builder) {
        this.name    = builder.name;
        this.email   = builder.email;
        this.age     = builder.age;
        this.phone   = builder.phone;
        this.address = builder.address;
        this.bio     = builder.bio;
    }
 
    // Getters
    public String getName()    { return name; }
    public String getEmail()   { return email; }
    public Integer getAge()    { return age; }
    public String getPhone()   { return phone; }
    public String getAddress() { return address; }
    public String getBio()     { return bio; }
 
    @Override
    public String toString() {
        return "User(name=" + name + ", email=" + email + ", age=" + age + ")";
    }
 
    // Static inner Builder class
    public static class Builder {
        // Required fields
        private final String name;
        private final String email;
 
        // Optional fields
        private Integer age;
        private String phone;
        private String address;
        private String bio;
 
        public Builder(String name, String email) {
            if (name == null || name.isEmpty()) throw new IllegalArgumentException("Name required");
            if (email == null || email.isEmpty()) throw new IllegalArgumentException("Email required");
            this.name = name;
            this.email = email;
        }
 
        public Builder age(int age) {
            if (age < 0 || age > 150) throw new IllegalArgumentException("Invalid age: " + age);
            this.age = age;
            return this;
        }
 
        public Builder phone(String phone) {
            this.phone = phone;
            return this;
        }
 
        public Builder address(String address) {
            this.address = address;
            return this;
        }
 
        public Builder bio(String bio) {
            this.bio = bio;
            return this;
        }
 
        public User build() {
            return new User(this);
        }
    }
}
 
// Usage
User minimalUser = new User.Builder("Alice", "alice@example.com")
        .build();
 
User fullUser = new User.Builder("Bob", "bob@example.com")
        .age(30)
        .phone("+1-555-0100")
        .address("123 Main St, New York")
        .bio("Full-stack developer")
        .build();
 
System.out.println(minimalUser);  // User(name=Alice, email=alice@example.com, age=null)
System.out.println(fullUser.getName()); // Bob

Note on Java style: The Java approach uses a static inner Builder class — this is the conventional Java idiom (popularized by Joshua Bloch's Effective Java). Required fields go in the Builder constructor; optional ones are chained setters.


Real-World Examples

1. HTTP Request Builder

A common real-world use case — building HTTP requests with many optional parts:

class HttpRequest {
  private constructor(
    public readonly method: string,
    public readonly url: string,
    public readonly headers: Record<string, string>,
    public readonly body?: string,
    public readonly timeout: number = 5000,
    public readonly retries: number = 0
  ) {}
 
  static builder(method: string, url: string): HttpRequestBuilder {
    return new HttpRequestBuilder(method, url);
  }
}
 
class HttpRequestBuilder {
  private _headers: Record<string, string> = {};
  private _body?: string;
  private _timeout = 5000;
  private _retries = 0;
 
  constructor(
    private readonly _method: string,
    private readonly _url: string
  ) {}
 
  header(key: string, value: string): HttpRequestBuilder {
    this._headers[key] = value;
    return this;
  }
 
  bearerToken(token: string): HttpRequestBuilder {
    return this.header("Authorization", `Bearer ${token}`);
  }
 
  jsonBody(data: object): HttpRequestBuilder {
    this._body = JSON.stringify(data);
    return this.header("Content-Type", "application/json");
  }
 
  timeout(ms: number): HttpRequestBuilder {
    this._timeout = ms;
    return this;
  }
 
  retries(count: number): HttpRequestBuilder {
    this._retries = count;
    return this;
  }
 
  build(): HttpRequest {
    return new (HttpRequest as any)(
      this._method,
      this._url,
      this._headers,
      this._body,
      this._timeout,
      this._retries
    );
  }
}
 
// ✅ Readable, explicit, extendable
const request = HttpRequest.builder("POST", "https://api.example.com/users")
  .bearerToken("abc123")
  .jsonBody({ name: "Alice", role: "admin" })
  .timeout(10_000)
  .retries(3)
  .build();

2. SQL Query Builder

Another classic — building SQL queries dynamically:

class QueryBuilder {
  private table = "";
  private conditions: string[] = [];
  private columns: string[] = ["*"];
  private orderColumn = "";
  private orderDir: "ASC" | "DESC" = "ASC";
  private limitVal?: number;
 
  from(table: string): QueryBuilder {
    this.table = table;
    return this;
  }
 
  select(...columns: string[]): QueryBuilder {
    this.columns = columns;
    return this;
  }
 
  where(condition: string): QueryBuilder {
    this.conditions.push(condition);
    return this;
  }
 
  orderBy(column: string, direction: "ASC" | "DESC" = "ASC"): QueryBuilder {
    this.orderColumn = column;
    this.orderDir = direction;
    return this;
  }
 
  limit(n: number): QueryBuilder {
    this.limitVal = n;
    return this;
  }
 
  build(): string {
    if (!this.table) throw new Error("Table name is required");
 
    let query = `SELECT ${this.columns.join(", ")} FROM ${this.table}`;
    if (this.conditions.length > 0) {
      query += ` WHERE ${this.conditions.join(" AND ")}`;
    }
    if (this.orderColumn) {
      query += ` ORDER BY ${this.orderColumn} ${this.orderDir}`;
    }
    if (this.limitVal !== undefined) {
      query += ` LIMIT ${this.limitVal}`;
    }
    return query;
  }
}
 
// Usage
const query = new QueryBuilder()
  .from("users")
  .select("id", "name", "email")
  .where("age > 18")
  .where("active = true")
  .orderBy("name", "ASC")
  .limit(10)
  .build();
 
// SELECT id, name, email FROM users WHERE age > 18 AND active = true ORDER BY name ASC LIMIT 10
console.log(query);

3. Notification Builder (Python)

from dataclasses import dataclass, field
from typing import List, Optional
from enum import Enum
 
 
class NotificationChannel(Enum):
    EMAIL = "email"
    SMS = "sms"
    PUSH = "push"
 
 
@dataclass
class Notification:
    title: str
    body: str
    channels: List[NotificationChannel]
    recipient: str
    priority: str = "normal"
    scheduled_at: Optional[str] = None
    metadata: dict = field(default_factory=dict)
 
 
class NotificationBuilder:
    def __init__(self) -> None:
        self._title: Optional[str] = None
        self._body: Optional[str] = None
        self._channels: List[NotificationChannel] = []
        self._recipient: Optional[str] = None
        self._priority = "normal"
        self._scheduled_at: Optional[str] = None
        self._metadata: dict = {}
 
    def title(self, title: str) -> "NotificationBuilder":
        self._title = title
        return self
 
    def body(self, body: str) -> "NotificationBuilder":
        self._body = body
        return self
 
    def to(self, recipient: str) -> "NotificationBuilder":
        self._recipient = recipient
        return self
 
    def via(self, *channels: NotificationChannel) -> "NotificationBuilder":
        self._channels.extend(channels)
        return self
 
    def urgent(self) -> "NotificationBuilder":
        self._priority = "high"
        return self
 
    def schedule(self, at: str) -> "NotificationBuilder":
        self._scheduled_at = at
        return self
 
    def with_metadata(self, **kwargs) -> "NotificationBuilder":
        self._metadata.update(kwargs)
        return self
 
    def build(self) -> Notification:
        if not self._title:
            raise ValueError("Title is required")
        if not self._body:
            raise ValueError("Body is required")
        if not self._recipient:
            raise ValueError("Recipient is required")
        if not self._channels:
            raise ValueError("At least one channel is required")
 
        return Notification(
            title=self._title,
            body=self._body,
            channels=self._channels,
            recipient=self._recipient,
            priority=self._priority,
            scheduled_at=self._scheduled_at,
            metadata=self._metadata,
        )
 
 
# Usage - reads like English
notification = (
    NotificationBuilder()
    .title("New Order")
    .body("Your order #1234 has shipped!")
    .to("user@example.com")
    .via(NotificationChannel.EMAIL, NotificationChannel.PUSH)
    .urgent()
    .with_metadata(order_id="1234", tracking="XYZ789")
    .build()
)

Builder vs Other Creational Patterns

PatternWhen to UseCreates
BuilderComplex object with many optional partsOne complex object
Factory MethodSubclasses decide which class to instantiateFamily of related objects
Abstract FactoryFamilies of related objects without specifying classesMultiple related objects
PrototypeCopy an existing objectClone of an object
SingletonExactly one instance is neededSingle shared instance

Use Builder when:

  • Object has 4+ constructor parameters (especially optional ones)
  • Object construction requires multiple steps
  • You want immutable objects with validation
  • You want a readable, self-documenting API

Don't use Builder when:

  • Object has only 1–3 simple required fields (overkill)
  • Object is mutable anyway (no immutability benefit)
  • A simple factory function suffices

Common Mistakes

1. Mutable Product

// ❌ Builder creates mutable object - defeats the purpose
class UserBuilder {
  build(): User {
    const user = new User();
    user.name = this._name; // Public setter - anyone can modify
    user.email = this._email;
    return user;
  }
}
 
// ✅ Builder creates immutable object
class UserBuilder {
  build(): User {
    return new User(this._name, this._email, ...); // Private constructor
  }
}

2. Forgetting to Return this

// ❌ Breaks chaining
class UserBuilder {
  setName(name: string): void { // Returns void!
    this._name = name;
    // forgot return this
  }
}
 
// ✅ Always return this from setter methods
class UserBuilder {
  setName(name: string): UserBuilder {
    this._name = name;
    return this;
  }
}

3. Skipping Validation in build()

// ❌ No validation - silently creates invalid objects
class UserBuilder {
  build(): User {
    return new User(this._name!, this._email!); // ! bypasses null check
  }
}
 
// ✅ Validate in build()
class UserBuilder {
  build(): User {
    if (!this._name) throw new Error("Name is required");
    if (!this._email || !this._email.includes("@")) {
      throw new Error("Valid email is required");
    }
    return new User(this._name, this._email);
  }
}

4. Builder Without Reset (Reuse Bug)

// ❌ Reusing builder creates contaminated objects
const builder = new UserBuilder().setName("Alice").setEmail("alice@example.com");
const alice = builder.build();
 
builder.setName("Bob"); // Forgot to set email!
const bob = builder.build(); // Bob has Alice's email!
 
// ✅ Either create a new builder each time, or add reset()
class UserBuilder {
  reset(): UserBuilder {
    this._name = undefined;
    this._email = undefined;
    this._age = undefined;
    return this;
  }
}

Builder in Frameworks You Already Use

You interact with the Builder pattern constantly:

Java Streams / StringBuilder:

String result = new StringBuilder()
    .append("Hello")
    .append(", ")
    .append("World")
    .toString();

Java HttpClient (Java 11+):

HttpClient client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)
    .connectTimeout(Duration.ofSeconds(10))
    .build();

Spring Boot MockMvc (testing):

mockMvc.perform(
    MockMvcRequestBuilders.post("/api/users")
        .contentType(MediaType.APPLICATION_JSON)
        .content(json)
)

Python requests session (builder-like):

session = requests.Session()
session.headers.update({"Authorization": "Bearer token"})
session.timeout = 30
response = session.get("https://api.example.com/data")

TypeScript Prisma ORM:

const users = await prisma.user.findMany({
  where: { active: true, age: { gte: 18 } },
  select: { id: true, name: true, email: true },
  orderBy: { name: "asc" },
  take: 10,
});

These are all Builder pattern variations under different names.


Summary

The Builder Pattern is one of the most practical creational patterns — you'll use it constantly in real-world code.

Key takeaways:

✅ Builder separates construction from representation
✅ Fluent interface (method chaining with return this) makes construction readable
✅ Validation belongs in build(), not in individual setters
✅ Private constructors enforce that only the builder can create the product
✅ Director is optional — use when you have common construction recipes
✅ Create a new builder instance per object (or add reset())

When to reach for Builder:

  • 4+ constructor parameters (especially optional ones)
  • Object needs step-by-step construction with validation
  • You want an immutable, self-documenting API

What's Next

You've completed Phase 3: Creational Patterns:

Then we move to Phase 4: Structural Patterns:


Part 10 of the OOP & Design Patterns series. Building on Factory and Abstract Factory — the previous creational pattern.

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.