Loading learning content...
In the previous page, we examined the challenges of constructing complex objects: telescoping constructors, mutable setter chaos, configuration object limitations, invariant fragmentation, and representation coupling. Now we introduce the elegant solution that addresses all these concerns.
The Builder Pattern is a creational design pattern that separates the construction of a complex object from its representation, allowing the same construction process to create different representations. It provides a step-by-step approach to object assembly, with each step returning a builder for method chaining, culminating in a final build method that produces the complete, validated, immutable object.
The core insight: Construction is a process. Representation is an outcome. By separating them, we gain extraordinary flexibility and safety.
By the end of this page, you will understand the fundamental architecture of the Builder Pattern. You'll learn about the Builder interface, Concrete Builders, and how they collaborate to produce complex objects. You'll see how separation of concerns transforms construction from a single monolithic operation into a composable, flexible process.
Let's begin with the formal definition from the Gang of Four's Design Patterns book:
Builder Pattern Intent: Separate the construction of a complex object from its representation so that the same construction process can create different representations.
This single sentence encapsulates profound design wisdom. Let's unpack each element:
Why this separation matters:
When construction and representation are intertwined (as in our earlier HTML document example), you face these problems:
The Builder Pattern eliminates all of these by enforcing separation.
Think of a house blueprint versus the materials used to build it. The blueprint (construction process) is the same whether you build with brick, wood, or steel (representations). An architect designs once; contractors can build with any materials that follow the blueprint.
The Builder Pattern involves several key participants that collaborate to achieve the separation of construction from representation. Understanding these roles is essential before we examine implementation.
| Participant | Role | Responsibility |
|---|---|---|
| Builder | Abstract Interface | Declares the steps for constructing parts of the Product. Defines the interface that all Concrete Builders must implement. |
| ConcreteBuilder | Implementation | Implements the Builder interface for a specific representation. Tracks the product being constructed and provides a way to retrieve it. |
| Product | Result | The complex object being constructed. Different ConcreteBuilders produce different Product types or configurations. |
| Director | Orchestrator (Optional) | Encapsulates a particular construction algorithm. Uses a Builder to execute steps in a specific order. |
| Client | Consumer | Creates the Builder, optionally creates a Director, and retrieves the final Product. |
The relationships between participants:
Notice that the Director has no knowledge of the concrete builder type—it works through the abstract Builder interface. This enables the same construction algorithm to produce different products.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// Builder - Abstract interface declaring construction stepsinterface DocumentBuilder { reset(): void; setTitle(title: string): void; addParagraph(text: string): void; addImage(path: string, caption: string): void; addTable(data: TableData): void; // Note: No getResult() here - that's representation-specific} // ConcreteBuilder - Implements Builder for HTML representationclass HtmlDocumentBuilder implements DocumentBuilder { private html: string = ''; reset(): void { this.html = ''; } setTitle(title: string): void { this.html += `<h1>${this.escapeHtml(title)}</h1>\n`; } addParagraph(text: string): void { this.html += `<p>${this.escapeHtml(text)}</p>\n`; } addImage(path: string, caption: string): void { this.html += `<figure>\n`; this.html += ` <img src="${path}" alt="${this.escapeHtml(caption)}" />\n`; this.html += ` <figcaption>${this.escapeHtml(caption)}</figcaption>\n`; this.html += `</figure>\n`; } addTable(data: TableData): void { this.html += '<table>\n'; // ... table rendering logic this.html += '</table>\n'; } // Product retrieval - specific to this concrete builder getResult(): HtmlDocument { return new HtmlDocument( `<!DOCTYPE html>\n<html>\n<body>\n${this.html}</body>\n</html>` ); } private escapeHtml(text: string): string { return text.replace(/[&<>"']/g, /* escape logic */); }} // Another ConcreteBuilder - Implements Builder for Markdownclass MarkdownDocumentBuilder implements DocumentBuilder { private markdown: string = ''; reset(): void { this.markdown = ''; } setTitle(title: string): void { this.markdown += `# ${title}\n\n`; } addParagraph(text: string): void { this.markdown += `${text}\n\n`; } addImage(path: string, caption: string): void { this.markdown += `\n\n`; } addTable(data: TableData): void { // ... markdown table rendering logic } getResult(): MarkdownDocument { return new MarkdownDocument(this.markdown); }} // Director - Encapsulates construction algorithmclass ReportDirector { private builder: DocumentBuilder; constructor(builder: DocumentBuilder) { this.builder = builder; } // Reusable construction algorithm constructAnnualReport(data: ReportData): void { this.builder.reset(); this.builder.setTitle(`Annual Report ${data.year}`); this.builder.addParagraph(data.executiveSummary); this.builder.addTable(data.financialData); this.builder.addImage(data.chartPath, 'Financial Overview'); // ... more steps }} // Client usageconst htmlBuilder = new HtmlDocumentBuilder();const markdownBuilder = new MarkdownDocumentBuilder(); const director = new ReportDirector(htmlBuilder);director.constructAnnualReport(reportData);const htmlReport = htmlBuilder.getResult(); // Same algorithm, different representationdirector = new ReportDirector(markdownBuilder);director.constructAnnualReport(reportData);const markdownReport = markdownBuilder.getResult();In many modern implementations, the Director is omitted. The Client calls builder methods directly, especially when using fluent interfaces. We'll explore the Director's role in detail in a later page.
The Builder interface is the heart of the pattern. It defines the contract that all concrete builders must fulfill, establishing what construction steps are available without specifying how each step is implemented.
Designing an effective Builder interface:
setTitle(), addSection(), addImage() are logical units of work.reset() method to clear state and allow builder reuse for multiple products.setTitle() with setTitleFontSizeAndColorAndAlignment().123456789101112131415161718192021222324252627282930313233343536373839404142
/** * Builder interface for constructing query objects. * Note: No getResult() method - that's concrete builder responsibility. */interface QueryBuilder { // Selection select(...columns: string[]): QueryBuilder; selectAll(): QueryBuilder; selectDistinct(...columns: string[]): QueryBuilder; // Source from(table: string): QueryBuilder; fromSubquery(subquery: QueryBuilder, alias: string): QueryBuilder; // Filtering where(condition: Condition): QueryBuilder; andWhere(condition: Condition): QueryBuilder; orWhere(condition: Condition): QueryBuilder; // Joining join(table: string, condition: JoinCondition): QueryBuilder; leftJoin(table: string, condition: JoinCondition): QueryBuilder; rightJoin(table: string, condition: JoinCondition): QueryBuilder; // Grouping and Ordering groupBy(...columns: string[]): QueryBuilder; having(condition: Condition): QueryBuilder; orderBy(column: string, direction?: 'ASC' | 'DESC'): QueryBuilder; // Limiting limit(count: number): QueryBuilder; offset(count: number): QueryBuilder; // Lifecycle reset(): QueryBuilder;} // Observations:// 1. Each method is a logical construction step// 2. Methods return QueryBuilder for fluent chaining// 3. No concrete product type mentioned// 4. Consistent level of abstraction throughoutThe getResult() question:
Notice that the abstract Builder interface doesn't include a getResult() method. This is intentional. Different concrete builders produce different product types:
SqlQueryBuilder.getResult() returns a SqlQuery with a string SQL statementMongoQueryBuilder.getResult() returns a MongoQuery with a MongoDB query documentElasticsearchQueryBuilder.getResult() returns an ElasticsearchQuery with an ES query DSLIf the interface declared getResult(): Query, we'd need a common base Query type—which might not make sense when products are fundamentally different.
The Builder interface becomes a contract between construction code and concrete builders. Changes to this interface ripple through all implementations. Design the interface carefully and prefer extension (adding new methods) over modification (changing existing signatures).
Concrete Builders implement the Builder interface for specific product representations. Each concrete builder:
Let's examine a comprehensive example with multiple concrete builders:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
// Abstract Builder interfaceinterface HttpRequestBuilder { setMethod(method: HttpMethod): HttpRequestBuilder; setUrl(url: string): HttpRequestBuilder; setHeader(name: string, value: string): HttpRequestBuilder; setQueryParam(name: string, value: string): HttpRequestBuilder; setBody(body: unknown): HttpRequestBuilder; setTimeout(ms: number): HttpRequestBuilder; reset(): HttpRequestBuilder;} type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; // ==========================================// Concrete Builder 1: Fetch API// ==========================================class FetchRequestBuilder implements HttpRequestBuilder { private method: HttpMethod = 'GET'; private url: string = ''; private headers: Headers = new Headers(); private queryParams: URLSearchParams = new URLSearchParams(); private body: unknown = undefined; private timeout: number = 30000; setMethod(method: HttpMethod): this { this.method = method; return this; } setUrl(url: string): this { this.url = url; return this; } setHeader(name: string, value: string): this { this.headers.set(name, value); return this; } setQueryParam(name: string, value: string): this { this.queryParams.set(name, value); return this; } setBody(body: unknown): this { this.body = body; return this; } setTimeout(ms: number): this { this.timeout = ms; return this; } reset(): this { this.method = 'GET'; this.url = ''; this.headers = new Headers(); this.queryParams = new URLSearchParams(); this.body = undefined; this.timeout = 30000; return this; } // Product: Fetch API Request getResult(): { input: RequestInfo; init: RequestInit } { const fullUrl = this.queryParams.toString() ? `${this.url}?${this.queryParams}` : this.url; return { input: fullUrl, init: { method: this.method, headers: this.headers, body: this.body ? JSON.stringify(this.body) : undefined, signal: AbortSignal.timeout(this.timeout), } }; }} // ==========================================// Concrete Builder 2: Axios Request// ==========================================class AxiosRequestBuilder implements HttpRequestBuilder { private config: AxiosRequestConfig = { method: 'get', url: '', headers: {}, params: {}, timeout: 30000, }; setMethod(method: HttpMethod): this { this.config.method = method.toLowerCase(); return this; } setUrl(url: string): this { this.config.url = url; return this; } setHeader(name: string, value: string): this { this.config.headers![name] = value; return this; } setQueryParam(name: string, value: string): this { this.config.params![name] = value; return this; } setBody(body: unknown): this { this.config.data = body; return this; } setTimeout(ms: number): this { this.config.timeout = ms; return this; } reset(): this { this.config = { method: 'get', url: '', headers: {}, params: {}, timeout: 30000, }; return this; } // Product: Axios Config getResult(): AxiosRequestConfig { return { ...this.config }; }} // ==========================================// Concrete Builder 3: cURL Command String// ==========================================class CurlCommandBuilder implements HttpRequestBuilder { private parts: string[] = ['curl']; private url: string = ''; private queryParams: Map<string, string> = new Map(); setMethod(method: HttpMethod): this { if (method !== 'GET') { this.parts.push(`-X ${method}`); } return this; } setUrl(url: string): this { this.url = url; return this; } setHeader(name: string, value: string): this { this.parts.push(`-H '${name}: ${value}'`); return this; } setQueryParam(name: string, value: string): this { this.queryParams.set(encodeURIComponent(name), encodeURIComponent(value)); return this; } setBody(body: unknown): this { const jsonBody = JSON.stringify(body).replace(/'/g, "'\''"); this.parts.push(`-d '${jsonBody}'`); return this; } setTimeout(ms: number): this { this.parts.push(`--max-time ${Math.ceil(ms / 1000)}`); return this; } reset(): this { this.parts = ['curl']; this.url = ''; this.queryParams = new Map(); return this; } // Product: cURL command string getResult(): string { let fullUrl = this.url; if (this.queryParams.size > 0) { const params = Array.from(this.queryParams) .map(([k, v]) => `${k}=${v}`) .join('&'); fullUrl += `?${params}`; } return [...this.parts, `'${fullUrl}'`].join(' '); }}Key observations:
Same interface, different internals — Each builder implements the same construction steps but maintains different internal state structures optimized for its product type.
Product types vary — FetchRequestBuilder produces Fetch API objects, AxiosRequestBuilder produces Axios configs, CurlCommandBuilder produces strings. No common base type needed.
Builder reuse — The reset() method allows reusing a builder for multiple products without creating new instances.
Fluent returns — Methods return this to enable method chaining (covered in depth next page).
Code that constructs requests doesn't need to know whether it's building for Fetch, Axios, or cURL. It works against the HttpRequestBuilder interface. At runtime, inject the appropriate concrete builder based on context—tests might use cURL for logging, production uses Fetch.
A critical responsibility of concrete builders is managing construction state. Unlike the setter pattern where the product accumulates state directly, the builder encapsulates state until construction is complete.
State management strategies:
| Strategy | Description | Best For |
|---|---|---|
| Aggregating State | Builder accumulates data in internal structures, then creates product at getResult() | When product is immutable or needs all data at once |
| Progressive Assembly | Builder constructs product incrementally, adding parts as methods are called | When product can grow naturally (DOM, strings) |
| Snapshot State | Builder maintains a current 'snapshot' that can be retrieved at any point | When partial results are useful |
| Computational State | Builder computes derived values on the fly during construction | When intermediate calculations inform later steps |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
/** * Builder that aggregates all configuration, * then creates an immutable product at build time. */class HttpClientConfigBuilder { // Internal state - mutable during construction private baseUrl: string = ''; private timeout: number = 30000; private retries: number = 0; private headers: Map<string, string> = new Map(); private interceptors: Interceptor[] = []; private auth: AuthConfig | null = null; setBaseUrl(url: string): this { this.baseUrl = url; return this; } setTimeout(ms: number): this { this.timeout = ms; return this; } setRetries(count: number): this { this.retries = count; return this; } addHeader(name: string, value: string): this { this.headers.set(name, value); return this; } addInterceptor(interceptor: Interceptor): this { this.interceptors.push(interceptor); return this; } setBasicAuth(username: string, password: string): this { this.auth = { type: 'basic', username, password }; return this; } setBearerToken(token: string): this { this.auth = { type: 'bearer', token }; return this; } // Final assembly: create IMMUTABLE product build(): HttpClientConfig { // Validate before creating product if (!this.baseUrl) { throw new Error('Base URL is required'); } // Create immutable product with all aggregated state return Object.freeze({ baseUrl: this.baseUrl, timeout: this.timeout, retries: this.retries, headers: Object.freeze(Object.fromEntries(this.headers)), interceptors: Object.freeze([...this.interceptors]), auth: this.auth ? Object.freeze({ ...this.auth }) : null, }); } reset(): this { this.baseUrl = ''; this.timeout = 30000; this.retries = 0; this.headers.clear(); this.interceptors = []; this.auth = null; return this; }} // Usageconst config = new HttpClientConfigBuilder() .setBaseUrl('https://api.example.com') .setTimeout(5000) .setRetries(3) .addHeader('Accept', 'application/json') .setBearerToken('secret-token') .build(); // config is now immutable - cannot be modified// Attempting config.timeout = 1000 would throw in strict modeThe immutability advantage:
By aggregating state in the mutable builder and producing an immutable product, we get the best of both worlds:
This is impossible with the setter pattern, where the object itself is directly mutated and remains mutable forever.
Object.freeze() only freezes the top level. For deeply nested products, you need recursive freezing (deep freeze) to ensure complete immutability. Libraries like Immer can help manage this.
One of the Builder Pattern's greatest strengths is its ability to validate at appropriate moments during construction. Unlike constructors that validate all at once or setters that can't validate cross-field dependencies, builders can validate incrementally and comprehensively.
Validation timing options:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
class DatabaseConnectionBuilder { private host?: string; private port?: number; private database?: string; private username?: string; private password?: string; private ssl?: { enabled: boolean; certificate?: string; key?: string; rejectUnauthorized?: boolean; }; private poolSize?: number; private connectionString?: string; // Immediate validation: individual field constraints setHost(host: string): this { if (!host || host.trim().length === 0) { throw new Error('Host cannot be empty'); } if (this.connectionString) { throw new Error('Cannot set host when connection string is already provided'); } this.host = host.trim(); return this; } setPort(port: number): this { if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error('Port must be an integer between 1 and 65535'); } if (this.connectionString) { throw new Error('Cannot set port when connection string is already provided'); } this.port = port; return this; } setPoolSize(size: number): this { if (!Number.isInteger(size) || size < 1 || size > 100) { throw new Error('Pool size must be an integer between 1 and 100'); } this.poolSize = size; return this; } setConnectionString(connectionString: string): this { if (this.host || this.port || this.database) { throw new Error('Cannot set connection string when individual parameters are already set'); } // Validate connection string format const pattern = /^postgres(ql)?:\/\/[^:]+:[^@]+@[^:]+:\d+\/\w+$/; if (!pattern.test(connectionString)) { throw new Error('Invalid connection string format'); } this.connectionString = connectionString; return this; } enableSsl(options?: { certificate?: string; key?: string; rejectUnauthorized?: boolean; }): this { this.ssl = { enabled: true, ...options }; return this; } // Progressive validation: SSL configuration complete private validateSslConfig(): void { if (this.ssl?.enabled) { const hasCert = !!this.ssl.certificate; const hasKey = !!this.ssl.key; if (hasCert !== hasKey) { throw new Error('SSL certificate and key must be provided together'); } } } // Deferred validation: complete state validation at build time build(): DatabaseConnection { // Validate: either connection string OR individual params if (this.connectionString) { return this.buildFromConnectionString(); } // Validate required fields if (!this.host) throw new Error('Host is required'); if (!this.port) throw new Error('Port is required'); if (!this.database) throw new Error('Database is required'); if (!this.username) throw new Error('Username is required'); if (!this.password) throw new Error('Password is required'); // Validate cross-field constraints this.validateSslConfig(); // All validations passed - create product return new DatabaseConnection({ host: this.host, port: this.port, database: this.database, username: this.username, password: this.password, ssl: this.ssl, poolSize: this.poolSize ?? 10, }); } private buildFromConnectionString(): DatabaseConnection { // Parse and create from connection string return DatabaseConnection.fromConnectionString(this.connectionString!); }} // Usage with fail-fast validationconst builder = new DatabaseConnectionBuilder(); try { builder.setHost(''); // Throws immediately: "Host cannot be empty"} catch (e) { console.error('Validation failed at setHost');} try { builder .setHost('localhost') .setPort(5432) .setConnectionString('postgres://...'); // Throws: conflict} catch (e) { console.error('Cannot mix connection modes');}The best validation catches errors as early as possible with clear messages. When a setter rejects invalid input immediately, the developer knows exactly which call failed. When build() fails, list all missing required fields—don't just fail on the first one.
The ultimate power of separating construction from representation is reuse. The same construction algorithm can produce wildly different outputs by swapping the builder implementation.
Let's see this with a complete example: generating user interface components for different platforms.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
// Builder interface for UI dialogsinterface DialogBuilder { setTitle(title: string): DialogBuilder; setMessage(message: string): DialogBuilder; addButton(label: string, action: () => void): DialogBuilder; setIcon(icon: 'info' | 'warning' | 'error' | 'success'): DialogBuilder; setModal(modal: boolean): DialogBuilder; reset(): DialogBuilder;} // Concrete Builder: HTML/CSS Dialogclass HtmlDialogBuilder implements DialogBuilder { private html: string[] = []; private css: string[] = []; private scripts: string[] = []; setTitle(title: string): this { this.html.push(`<h2 class="dialog-title">${title}</h2>`); return this; } setMessage(message: string): this { this.html.push(`<p class="dialog-message">${message}</p>`); return this; } addButton(label: string, action: () => void): this { const buttonId = `btn-${Date.now()}-${Math.random().toString(36).substr(2)}`; this.html.push(`<button id="${buttonId}" class="dialog-button">${label}</button>`); this.scripts.push(`document.getElementById('${buttonId}').onclick = ${action.toString()};`); return this; } setIcon(icon: 'info' | 'warning' | 'error' | 'success'): this { const iconClass = `dialog-icon-${icon}`; this.html.unshift(`<div class="${iconClass}"></div>`); return this; } setModal(modal: boolean): this { if (modal) { this.css.push('.dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); }'); } return this; } reset(): this { this.html = []; this.css = []; this.scripts = []; return this; } getResult(): { html: string; css: string; js: string } { return { html: `<div class="dialog">${this.html.join('\n')}</div>`, css: this.css.join('\n'), js: this.scripts.join('\n'), }; }} // Concrete Builder: React Componentsclass ReactDialogBuilder implements DialogBuilder { private props: Record<string, any> = {}; private children: Array<{ type: string; props: any }> = []; setTitle(title: string): this { this.children.push({ type: 'DialogTitle', props: { children: title } }); return this; } setMessage(message: string): this { this.children.push({ type: 'DialogContent', props: { children: message } }); return this; } addButton(label: string, action: () => void): this { this.children.push({ type: 'Button', props: { onClick: action, children: label } }); return this; } setIcon(icon: 'info' | 'warning' | 'error' | 'success'): this { this.props.icon = icon; return this; } setModal(modal: boolean): this { this.props.modal = modal; return this; } reset(): this { this.props = {}; this.children = []; return this; } getResult(): ReactElement { return createElement(Dialog, this.props, ...this.children.map(c => createElement(c.type, c.props)) ); }} // Concrete Builder: Native iOS (Swift code generation)class SwiftDialogBuilder implements DialogBuilder { private code: string[] = []; setTitle(title: string): this { this.code.push(`alert.title = "${title}"`); return this; } setMessage(message: string): this { this.code.push(`alert.message = "${message}"`); return this; } addButton(label: string, action: () => void): this { this.code.push(`alert.addAction(UIAlertAction(title: "${label}", style: .default))`); return this; } // ... other methods getResult(): string { return `let alert = UIAlertController(style: .alert)${this.code.join('\n')}present(alert, animated: true) `.trim(); }} // ==========================================// The Director: Reusable Construction Logic// ==========================================function constructConfirmationDialog( builder: DialogBuilder, title: string, message: string, onConfirm: () => void, onCancel: () => void): void { builder .reset() .setTitle(title) .setMessage(message) .setIcon('warning') .setModal(true) .addButton('Cancel', onCancel) .addButton('Confirm', onConfirm);} // Same construction, different platformsconst htmlBuilder = new HtmlDialogBuilder();const reactBuilder = new ReactDialogBuilder();const swiftBuilder = new SwiftDialogBuilder(); constructConfirmationDialog(htmlBuilder, 'Delete?', 'This cannot be undone.', handleConfirm, handleCancel);const webDialog = htmlBuilder.getResult(); constructConfirmationDialog(reactBuilder, 'Delete?', 'This cannot be undone.', handleConfirm, handleCancel);const reactDialog = reactBuilder.getResult(); constructConfirmationDialog(swiftBuilder, 'Delete?', 'This cannot be undone.', noop, noop);const iosCode = swiftBuilder.getResult();The constructConfirmationDialog function is written once and works with any DialogBuilder implementation. Add a new platform (Android, terminal UI, accessibility mode) by creating a new builder—zero changes to construction logic.
We've covered the fundamental architecture of the Builder Pattern. Let's consolidate the key concepts:
What's next:
Now that you understand the separation principle and pattern architecture, we'll explore one of the Builder Pattern's most practical and popular forms: fluent builder interfaces. Fluent builders use method chaining to create expressive, readable construction code that reads almost like natural language.
You now understand the core architecture of the Builder Pattern: the separation of construction from representation, the roles of Builder interface and Concrete Builders, state management strategies, and validation approaches. Next, we'll make builders delightful to use with fluent interfaces.