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
| Scenario | Why Builder Helps |
|---|---|
| Many optional parameters (>4) | Avoids telescoping constructors |
| Valid combinations vary | Builder can enforce constraints at build-time |
| Immutable objects | Build step-by-step, freeze on build() |
| Different representations of the same construction | Director + multiple builders |
| Complex object creation with validation | Centralize validation in build() |
When NOT to Use
| Scenario | Why |
|---|---|
| Object has 1-3 params | Constructor is simpler |
| All params are required | No benefit from step-by-step |
| Simple data transfer objects | A 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
- Builder separates construction from representation -- build complex objects step by step without telescoping constructors.
- Fluent interface (returning
this) makes builder code self-documenting and pleasant to write. - Use the Director when you have reusable recipes for building objects.
- The
build()method is the perfect place for final validation and freezing the object. - Don't use Builder for objects with few, simple, required parameters -- a constructor is fine.
- 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.