Episode 9 — System Design / 9.3 — Creational Design Patterns

9.3.d Builder Pattern

The Problem It Solves

Some objects are complex to construct. They have many parameters, most of which are optional, and the valid combinations aren't obvious. The constructor becomes unwieldy:

// BEFORE: Telescoping constructor anti-pattern
const user = new User(
  'Alice',           // name
  'alice@test.com',  // email
  25,                // age
  null,              // phone (optional)
  'New York',        // city (optional)
  true,              // isAdmin (optional)
  false,             // isVerified (optional)
  'dark',            // theme (optional)
  null,              // avatar (optional)
  'en',              // locale (optional)
  true               // notifications (optional)
);
// What's the 4th parameter? The 7th? Impossible to read.

// AFTER: Builder pattern
const user = new UserBuilder('Alice', 'alice@test.com')
  .setAge(25)
  .setCity('New York')
  .setAdmin(true)
  .setTheme('dark')
  .setLocale('en')
  .enableNotifications()
  .build();
// Self-documenting, readable, flexible

The Builder pattern separates construction from representation, allowing the same construction process to create different objects.


UML Diagram

+-------------------+         +-------------------+
|     Director      |         |     Builder       |
|-------------------|         |-------------------|
| - builder         |-------->| + buildStepA()    |
| + construct()     |         | + buildStepB()    |
+-------------------+         | + buildStepC()    |
                              | + getResult()     |
                              +-------------------+
                                       ^
                                       |
                            +-------------------+
                            | ConcreteBuilder   |
                            |-------------------|
                            | - product         |
                            | + buildStepA()    |
                            | + buildStepB()    |
                            | + buildStepC()    |
                            | + getResult()     |
                            +-------------------+
                                       |
                                       v
                              +-------------------+
                              |    Product        |
                              |-------------------|
                              | - partA           |
                              | - partB           |
                              | - partC           |
                              +-------------------+

  Client --> Director --> Builder --> Product
  OR
  Client --> Builder --> Product  (no Director needed)

Core Concepts

1. Fluent Interface

Each setter method returns this, enabling method chaining:

builder
  .setName('Alice')    // returns builder
  .setAge(25)          // returns builder
  .setCity('NYC')      // returns builder
  .build();            // returns the final product

2. Director (Optional)

A Director encapsulates common construction recipes. It's useful when the same step-by-step process is repeated in many places.

3. Build Method

The build() method finalizes construction, validates the object, and returns the product. After build(), the builder can optionally be reset for reuse.


Implementation 1: User Profile Builder

class UserProfile {
  constructor(builder) {
    // Required
    this.name = builder.name;
    this.email = builder.email;

    // Optional (with defaults from builder)
    this.age = builder.age;
    this.phone = builder.phone;
    this.city = builder.city;
    this.isAdmin = builder.isAdmin;
    this.isVerified = builder.isVerified;
    this.theme = builder.theme;
    this.avatar = builder.avatar;
    this.locale = builder.locale;
    this.notifications = builder.notifications;

    // Freeze to prevent mutation after construction
    Object.freeze(this);
  }

  describe() {
    return `${this.name} (${this.email}) - ${this.city || 'No city'}, ` +
           `Admin: ${this.isAdmin}, Theme: ${this.theme}`;
  }
}

class UserProfileBuilder {
  constructor(name, email) {
    // Required params in constructor
    if (!name || !email) {
      throw new Error('Name and email are required');
    }
    this.name = name;
    this.email = email;

    // Defaults for optional params
    this.age = null;
    this.phone = null;
    this.city = null;
    this.isAdmin = false;
    this.isVerified = false;
    this.theme = 'light';
    this.avatar = null;
    this.locale = 'en';
    this.notifications = true;
  }

  setAge(age) {
    if (age < 0 || age > 150) throw new Error('Invalid age');
    this.age = age;
    return this;
  }

  setPhone(phone) {
    this.phone = phone;
    return this;
  }

  setCity(city) {
    this.city = city;
    return this;
  }

  setAdmin(isAdmin) {
    this.isAdmin = isAdmin;
    return this;
  }

  setVerified(isVerified) {
    this.isVerified = isVerified;
    return this;
  }

  setTheme(theme) {
    const validThemes = ['light', 'dark', 'system'];
    if (!validThemes.includes(theme)) {
      throw new Error(`Invalid theme. Must be one of: ${validThemes.join(', ')}`);
    }
    this.theme = theme;
    return this;
  }

  setAvatar(url) {
    this.avatar = url;
    return this;
  }

  setLocale(locale) {
    this.locale = locale;
    return this;
  }

  enableNotifications() {
    this.notifications = true;
    return this;
  }

  disableNotifications() {
    this.notifications = false;
    return this;
  }

  build() {
    // Final validation
    if (this.isAdmin && !this.isVerified) {
      throw new Error('Admin users must be verified');
    }
    return new UserProfile(this);
  }
}

// --- Usage ---
const admin = new UserProfileBuilder('Alice', 'alice@corp.com')
  .setAge(30)
  .setCity('San Francisco')
  .setAdmin(true)
  .setVerified(true)
  .setTheme('dark')
  .disableNotifications()
  .build();

console.log(admin.describe());
// Alice (alice@corp.com) - San Francisco, Admin: true, Theme: dark

const basicUser = new UserProfileBuilder('Bob', 'bob@email.com')
  .setCity('Austin')
  .build();

console.log(basicUser.describe());
// Bob (bob@email.com) - Austin, Admin: false, Theme: light

Implementation 2: Query Builder

One of the most common real-world uses of the Builder pattern.

class SQLQueryBuilder {
  constructor() {
    this.reset();
  }

  reset() {
    this._type = null;
    this._table = null;
    this._fields = ['*'];
    this._conditions = [];
    this._orderBy = [];
    this._limit = null;
    this._offset = null;
    this._joins = [];
    this._groupBy = [];
    this._having = null;
    this._values = {};
    return this;
  }

  // --- Query Type ---
  select(...fields) {
    this._type = 'SELECT';
    if (fields.length > 0) this._fields = fields;
    return this;
  }

  insert(values) {
    this._type = 'INSERT';
    this._values = values;
    return this;
  }

  update(values) {
    this._type = 'UPDATE';
    this._values = values;
    return this;
  }

  delete() {
    this._type = 'DELETE';
    return this;
  }

  // --- Clauses ---
  from(table) {
    this._table = table;
    return this;
  }

  into(table) {
    this._table = table;
    return this;
  }

  table(table) {
    this._table = table;
    return this;
  }

  where(condition) {
    this._conditions.push({ type: 'AND', condition });
    return this;
  }

  orWhere(condition) {
    this._conditions.push({ type: 'OR', condition });
    return this;
  }

  join(table, on) {
    this._joins.push({ type: 'INNER JOIN', table, on });
    return this;
  }

  leftJoin(table, on) {
    this._joins.push({ type: 'LEFT JOIN', table, on });
    return this;
  }

  orderBy(field, direction = 'ASC') {
    this._orderBy.push(`${field} ${direction}`);
    return this;
  }

  groupBy(...fields) {
    this._groupBy.push(...fields);
    return this;
  }

  having(condition) {
    this._having = condition;
    return this;
  }

  limit(n) {
    this._limit = n;
    return this;
  }

  offset(n) {
    this._offset = n;
    return this;
  }

  // --- Build ---
  build() {
    if (!this._table) throw new Error('Table is required');
    if (!this._type) throw new Error('Query type is required (select, insert, update, delete)');

    switch (this._type) {
      case 'SELECT': return this._buildSelect();
      case 'INSERT': return this._buildInsert();
      case 'UPDATE': return this._buildUpdate();
      case 'DELETE': return this._buildDelete();
      default: throw new Error(`Unknown query type: ${this._type}`);
    }
  }

  _buildSelect() {
    let sql = `SELECT ${this._fields.join(', ')} FROM ${this._table}`;
    sql += this._buildJoins();
    sql += this._buildWhere();
    sql += this._buildGroupBy();
    if (this._having) sql += ` HAVING ${this._having}`;
    if (this._orderBy.length) sql += ` ORDER BY ${this._orderBy.join(', ')}`;
    if (this._limit != null) sql += ` LIMIT ${this._limit}`;
    if (this._offset != null) sql += ` OFFSET ${this._offset}`;
    return sql + ';';
  }

  _buildInsert() {
    const keys = Object.keys(this._values);
    const vals = Object.values(this._values).map((v) =>
      typeof v === 'string' ? `'${v}'` : v
    );
    return `INSERT INTO ${this._table} (${keys.join(', ')}) VALUES (${vals.join(', ')});`;
  }

  _buildUpdate() {
    const sets = Object.entries(this._values)
      .map(([k, v]) => `${k} = ${typeof v === 'string' ? `'${v}'` : v}`)
      .join(', ');
    let sql = `UPDATE ${this._table} SET ${sets}`;
    sql += this._buildWhere();
    return sql + ';';
  }

  _buildDelete() {
    let sql = `DELETE FROM ${this._table}`;
    sql += this._buildWhere();
    return sql + ';';
  }

  _buildJoins() {
    return this._joins
      .map((j) => ` ${j.type} ${j.table} ON ${j.on}`)
      .join('');
  }

  _buildWhere() {
    if (!this._conditions.length) return '';
    return ' WHERE ' + this._conditions
      .map((c, i) => (i === 0 ? c.condition : `${c.type} ${c.condition}`))
      .join(' ');
  }

  _buildGroupBy() {
    if (!this._groupBy.length) return '';
    return ` GROUP BY ${this._groupBy.join(', ')}`;
  }
}

// --- Usage ---
const query1 = new SQLQueryBuilder()
  .select('u.name', 'u.email', 'COUNT(o.id) as order_count')
  .from('users u')
  .leftJoin('orders o', 'u.id = o.user_id')
  .where('u.active = true')
  .where('u.created_at > "2025-01-01"')
  .groupBy('u.id')
  .having('COUNT(o.id) > 5')
  .orderBy('order_count', 'DESC')
  .limit(10)
  .build();

console.log(query1);
// SELECT u.name, u.email, COUNT(o.id) as order_count FROM users u
// LEFT JOIN orders o ON u.id = o.user_id
// WHERE u.active = true AND u.created_at > "2025-01-01"
// GROUP BY u.id HAVING COUNT(o.id) > 5
// ORDER BY order_count DESC LIMIT 10;

const query2 = new SQLQueryBuilder()
  .insert({ name: 'Alice', email: 'alice@test.com', age: 30 })
  .into('users')
  .build();

console.log(query2);
// INSERT INTO users (name, email, age) VALUES ('Alice', 'alice@test.com', 30);

const query3 = new SQLQueryBuilder()
  .update({ status: 'shipped', updated_at: 'NOW()' })
  .table('orders')
  .where('id = 42')
  .build();

console.log(query3);
// UPDATE orders SET status = 'shipped', updated_at = 'NOW()' WHERE id = 42;

Implementation 3: HTTP Request Builder

class HTTPRequest {
  constructor(builder) {
    this.method = builder._method;
    this.url = builder._url;
    this.headers = { ...builder._headers };
    this.body = builder._body;
    this.timeout = builder._timeout;
    this.retries = builder._retries;
    this.auth = builder._auth ? { ...builder._auth } : null;
    Object.freeze(this);
  }

  describe() {
    const lines = [
      `${this.method} ${this.url}`,
      ...Object.entries(this.headers).map(([k, v]) => `${k}: ${v}`),
    ];
    if (this.body) lines.push('', JSON.stringify(this.body, null, 2));
    if (this.auth) lines.push(`Auth: ${this.auth.type}`);
    if (this.timeout) lines.push(`Timeout: ${this.timeout}ms`);
    if (this.retries) lines.push(`Retries: ${this.retries}`);
    return lines.join('\n');
  }
}

class HTTPRequestBuilder {
  constructor() {
    this._method = 'GET';
    this._url = '';
    this._headers = {};
    this._body = null;
    this._timeout = 30000;
    this._retries = 0;
    this._auth = null;
  }

  // --- Method shortcuts ---
  get(url) { this._method = 'GET'; this._url = url; return this; }
  post(url) { this._method = 'POST'; this._url = url; return this; }
  put(url) { this._method = 'PUT'; this._url = url; return this; }
  patch(url) { this._method = 'PATCH'; this._url = url; return this; }
  delete(url) { this._method = 'DELETE'; this._url = url; return this; }

  // --- Configuration ---
  header(key, value) {
    this._headers[key] = value;
    return this;
  }

  contentType(type) {
    this._headers['Content-Type'] = type;
    return this;
  }

  json(data) {
    this._body = data;
    this._headers['Content-Type'] = 'application/json';
    return this;
  }

  timeout(ms) {
    this._timeout = ms;
    return this;
  }

  retry(count) {
    this._retries = count;
    return this;
  }

  bearerToken(token) {
    this._auth = { type: 'Bearer', token };
    this._headers['Authorization'] = `Bearer ${token}`;
    return this;
  }

  basicAuth(username, password) {
    const encoded = Buffer.from(`${username}:${password}`).toString('base64');
    this._auth = { type: 'Basic', encoded };
    this._headers['Authorization'] = `Basic ${encoded}`;
    return this;
  }

  // --- Build ---
  build() {
    if (!this._url) throw new Error('URL is required');
    if (['POST', 'PUT', 'PATCH'].includes(this._method) && !this._body) {
      console.warn(`Warning: ${this._method} request without a body`);
    }
    return new HTTPRequest(this);
  }
}

// --- Usage ---
const request1 = new HTTPRequestBuilder()
  .post('https://api.example.com/users')
  .json({ name: 'Alice', email: 'alice@test.com' })
  .bearerToken('eyJhbGciOiJIUzI1NiIsInR5...')
  .timeout(5000)
  .retry(3)
  .build();

console.log(request1.describe());
/*
POST https://api.example.com/users
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5...

{
  "name": "Alice",
  "email": "alice@test.com"
}
Auth: Bearer
Timeout: 5000ms
Retries: 3
*/

const request2 = new HTTPRequestBuilder()
  .get('https://api.example.com/users?page=1')
  .header('Accept', 'application/json')
  .header('X-Request-ID', 'abc-123')
  .timeout(10000)
  .build();

console.log(request2.describe());
/*
GET https://api.example.com/users?page=1
Accept: application/json
X-Request-ID: abc-123
Timeout: 10000ms
*/

The Director Pattern

When you have predefined recipes for building objects, a Director encapsulates them:

class RequestDirector {
  constructor(builder) {
    this.builder = builder;
  }

  // Pre-defined recipe: Authenticated JSON API call
  buildAuthenticatedAPIRequest(method, url, token, body = null) {
    let b = this.builder
      .header('Accept', 'application/json')
      .header('X-API-Version', '2')
      .bearerToken(token)
      .timeout(10000)
      .retry(2);

    // Set method
    if (method === 'GET') b = b.get(url);
    else if (method === 'POST') b = b.post(url);
    else if (method === 'PUT') b = b.put(url);
    else if (method === 'DELETE') b = b.delete(url);

    if (body) b = b.json(body);

    return b.build();
  }

  // Pre-defined recipe: File upload
  buildFileUpload(url, token) {
    return this.builder
      .post(url)
      .bearerToken(token)
      .contentType('multipart/form-data')
      .timeout(60000)
      .retry(1)
      .build();
  }

  // Pre-defined recipe: Health check
  buildHealthCheck(url) {
    return this.builder
      .get(url)
      .timeout(3000)
      .retry(0)
      .build();
  }
}

// --- Usage ---
const director = new RequestDirector(new HTTPRequestBuilder());

const apiCall = director.buildAuthenticatedAPIRequest(
  'POST',
  'https://api.example.com/orders',
  'my-token',
  { item: 'widget', qty: 5 }
);
console.log(apiCall.describe());

const healthCheck = director.buildHealthCheck('https://api.example.com/health');
console.log(healthCheck.describe());

When to use a Director:

  • The same construction steps are repeated in multiple places.
  • You want to give friendly names to complex construction sequences.
  • You want to hide the builder API from most callers.

When NOT to use a Director:

  • Each construction is unique -- the builder alone is enough.
  • You only have one or two simple recipes.

When to Use the Builder Pattern

ScenarioWhy Builder Helps
Many optional parameters (>4)Avoids telescoping constructors
Valid combinations varyBuilder can enforce constraints at build-time
Immutable objectsBuild step-by-step, freeze on build()
Different representations of the same constructionDirector + multiple builders
Complex object creation with validationCentralize validation in build()

When NOT to Use

ScenarioWhy
Object has 1-3 paramsConstructor is simpler
All params are requiredNo benefit from step-by-step
Simple data transfer objectsA plain object literal suffices

Before / After

Before: Config Object Overload

// 15 possible options, hard to remember which go where
const server = new Server({
  port: 3000,
  host: '0.0.0.0',
  cors: true,
  corsOrigin: '*',
  rateLimit: true,
  rateLimitMax: 100,
  rateLimitWindow: 60000,
  ssl: false,
  sslKey: null,
  sslCert: null,
  logging: true,
  logLevel: 'info',
  compression: true,
  bodyLimit: '10mb',
  timeout: 30000,
});

After: Builder with Grouped Methods

const server = new ServerBuilder()
  .listenOn(3000, '0.0.0.0')
  .enableCORS('*')
  .enableRateLimit({ max: 100, windowMs: 60000 })
  .enableLogging('info')
  .enableCompression()
  .setBodyLimit('10mb')
  .setTimeout(30000)
  .build();
// Readable, discoverable, validates on build()

Key Takeaways

  1. Builder separates construction from representation -- build complex objects step by step without telescoping constructors.
  2. Fluent interface (returning this) makes builder code self-documenting and pleasant to write.
  3. Use the Director when you have reusable recipes for building objects.
  4. The build() method is the perfect place for final validation and freezing the object.
  5. Don't use Builder for objects with few, simple, required parameters -- a constructor is fine.
  6. Builder is one of the most practically useful patterns in real codebases (query builders, request builders, configuration objects).

Explain-It Challenge: Your team's createUser() function takes 14 parameters. Three of them are required, and the rest have defaults. Explain to a colleague why refactoring to a Builder pattern improves both readability and correctness, in three sentences.