Builder pattern.
The Builder Pattern is a creational design pattern that separates the construction process of a complex object from its representation, allowing us to build objects step by step with clarity and flexibility. Instead of having long constructors or inconsistent object states( where objects which miss required properties might exist in memory), a builder guides the client through a safe, fluent process that results in fully-formed, valid objects. The builder pattern can be used to ensure API safety, architectural clarity, and taming messy boundaries however, the builder pattern may sometimes not be necessary as we explain in this blog.
1. The problem: Bloated constructors
The example below shows a naive constructor that has too many arguments whose order is so fragile.
// Naïve constructor: too many args, fragile order
class Report {
constructor(title, author, content, format, footer, createdAt) {
this.title = title;
this.author = author;
this.content = content;
this.format = format;
this.footer = footer;
this.createdAt = createdAt;
}
}
const report = new Report(
"Q3 Analytics",
"Koome Kelvin",
"Data goes here...",
"PDF",
"Confidential",
new Date()
);
// This example is hard to read, easy to misplace arguments and
// provides no guidance on what’s optional vs required
2. Fix to problem in #1: We use the builder pattern
In the example below we make use of the builder pattern to easily create a Report object without dealing with a long or confusing constructor. The Report class itself stays clean, only defining the fields that make up a report. The ReportBuilder takes responsibility for construction details: it sets sensible defaults, lets us add values step by step through a fluent API, and finally produces a valid Report instance with build(). The result is code that’s safe, readable, and self-explanatory, turning object creation into a clear sequence of instructions rather than a fragile constructor call.
class Report {
constructor({ title, author, content, format, footer, createdAt }) {
this.title = title;
this.author = author;
this.content = content;
this.format = format;
this.footer = footer;
this.createdAt = createdAt;
}
}
class ReportBuilder {
constructor() {
// sensible defaults
this.title = "Untitled Report";
this.format = "PDF";
this.createdAt = new Date();
}
setTitle(title) { this.title = title; return this; }
setAuthor(author) { this.author = author; return this; }
setContent(content) { this.content = content; return this; }
setFooter(footer) { this.footer = footer; return this; }
build() {
// Builder produces the domain object
return new Report(this);
}
}
// Usage is safe and expressive
const report = new ReportBuilder()
.setTitle("Q3 Analytics")
.setAuthor("Koome Kelvin")
.setContent("Data goes here...")
.setFooter("Confidential")
.build();
3. But wait how should the Report and Builder interact.
-a. One way dependency Here the domain does not know the builder exists, the builder depends on the domain.
// Report is unaware of the builder
class Report {
constructor({ title, author, content, format, footer, createdAt }) {
this.title = title;
this.author = author;
this.content = content;
this.format = format;
this.footer = footer;
this.createdAt = createdAt;
}
}
class ReportBuilder {
build() {
return new Report(this);
}
}
- b. Convinience hook Here the report is aware of the specific builder however this leads to tight coupling.
class Report {
constructor(data) { Object.assign(this, data); }
// A static factory method for discoverability my IDE, when one types Report.
// autocomplete will show me builder().
static builder() {
return new ReportBuilder();
}
}
class ReportBuilder {
build() { return new Report(this); }
}
// Usage
const report = Report.builder().setTitle("Q3").build();
- c. Injection (DDD / enterprise style) In the case we may need different builders (JsonReportBuilder, DbReportBuilder or more) this is a good option though it is overhead for small apps, but valuable in Domain-Driven Design.
class ReportService {
constructor(reportBuilder) {
this.reportBuilder = reportBuilder; // injected dependency
}
generateQuarterlyReport(data) {
return this.reportBuilder
.setTitle("Quarterly Analytics")
.setAuthor(data.author)
.setContent(data.content)
.build();
}
}
4. Builder pattern can be used as an API design strategy
As you can see the example below the sequence is self-documenting with Impossible states (e.g., calling orderBy before select) being harder to express. Therefore, Builder enforces validity through structure.
const query = new SqlQueryBuilder()
.select("id, name")
.from("users")
.where("age > 18")
.orderBy("name")
.build();
5. Underpinning Seperation of Concerns
In the example below (Profile) we interact with external API(could have latency issues), cache (may be stale) and database (could have schema mapping usses) therefore we group all that logic under the ProfileBuilder thus we embrace separation of concern by not bloating the Profile with infrastructure issues.
class Profile {
constructor({ user, settings, permissions }) {
this.user = user;
this.settings = settings;
this.permissions = permissions;
}
}
class ProfileBuilder {
constructor(userId) {
this.userId = userId;
}
async build() {
const [user, settings, permissions] = await Promise.all([
userApi.fetch(this.userId),
settingsCache.get(this.userId),
permissionsDb.query(this.userId),
]);
return new Profile({ user, settings, permissions });
}
}
// Usage
const profile = await new ProfileBuilder("u123").build();
So in the above code
- Builder handles (async orchestration, multiple sources, transformations)
- Domain stays pure - Profile is just a container of business meaning (user, settings, permissions).
- Consumer stays clean - usage looks simple (await new ProfileBuilder("u123").build()).
6. Where would it not be convinient to use Builder
- 1. Stable, Simple Domain example
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
const p = new Point(10, 20);
// No need for a builder
When the domain is simple and stable and parameters are few and self-explanatory We may not need Builder. Here we can go for immutability and defaults.
class Config {
constructor({ retries = 3, timeout = 5000 } = {}) {
this.retries = retries;
this.timeout = timeout;
Object.freeze(this); // enforce immutability
}
}
const defaultConfig = new Config();
const customConfig = new Config({ retries: 5 });
// Cleaner than introducing a builder
- 2. If object is just a bag of values, Builder only adds noise.
// Over-engineered builder
const settings = new SettingsBuilder()
.setDarkMode(true)
.setFontSize(14)
.setLanguage("en")
.build();
// Simpler alternative
const settings = { darkMode: true, fontSize: 14, language: "en" };
javascript book
If this interested you, check out my Javascript Book