Builder Pattern
CREATIONAL PATTERN · DESIGN PATTERNS SERIES #2
How many parameters is too many? Four? Six? There’s a constructor somewhere in every codebase that crossed the line, the one where you have to count commas to figure out which argument goes where, and where passing null for “optional” fields became completely normal.
The Builder pattern exists to kill that constructor. It replaces a wall of parameters with a clean, readable, step-by-step object construction process where every field is named, nothing is mandatory unless it has to be, and the final object is immutable once built.
What Is the Builder Pattern?
Builder is a creational design pattern. Its job is to separate the construction of a complex object from its representation, letting you build the same type of object through different configurations without overloading constructors or leaking mutable state.
The core idea has two parts:
- A static inner
Builderclass – accumulates fields one by one, each setter returningthisfor chaining. - A
build()method – validates what it has and constructs the final, immutable object.
The outer class has a private constructor. The only way in is through the Builder. That’s the contract.
The Problem in Code
You’re building an email service. An email has a recipient, a subject, a body and then a pile of optional things: CC, BCC, reply-to, attachments, HTML flag. The naive approach looks like this:
Email.java before Builder
public class Email {
private String to;
private String subject;
private String body;
private String cc;
private String bcc;
private String replyTo;
private List<String> attachments;
private boolean isHtml;
// Which null goes where? Good luck six months from now.
public Email(String to, String subject, String body,
String cc, String bcc, String replyTo,
List<String> attachments, boolean isHtml) {
this.to = to;
this.subject = subject;
// ...
}
}
// The caller. Read it without checking the constructor signature. You can't.
Email email = new Email(
"jan@example.com",
"Reset your password",
"Click the link below.",
null,
null,
null,
null,
false
);
Four nulls in a row. No names. No indication of what each position means. This is the telescoping constructor problem and it only gets worse as requirements grow.
The Solution, Step by Step
1. Make the outer class immutable
All fields are final. The constructor is private and only the inner Builder can call it. Once an Email exists, it cannot change.
Email.java – immutable outer class
public class Email {
// Required
private final String to;
// Optional
private final String subject;
private final String body;
private final String cc;
private final String bcc;
private final String replyTo;
private final List<String> attachments;
private final boolean isHtml;
// Private — only Builder gets in
private Email(Builder builder) {
this.to = builder.to;
this.subject = builder.subject;
this.body = builder.body;
this.cc = builder.cc;
this.bcc = builder.bcc;
this.replyTo = builder.replyTo;
this.attachments = builder.attachments;
this.isHtml = builder.isHtml;
}
// Getters only — no setters
public String getTo() { return to; }
public String getSubject() { return subject; }
public String getBody() { return body; }
public String getCc() { return cc; }
public String getBcc() { return bcc; }
public String getReplyTo() { return replyTo; }
public List<String> getAttachments(){ return attachments; }
public boolean isHtml() { return isHtml; }
}
2. Build the inner Builder class
The Builder mirrors the fields of Email. Required fields go into the Builder’s constructor so they can’t be skipped. Everything optional gets its own fluent setter returning this.
Email.java – inner Builder class
public static class Builder {
// Required — set via constructor
private final String to;
// Optional — sensible defaults
private String subject = "";
private String body = "";
private String cc = null;
private String bcc = null;
private String replyTo = null;
private List<String> attachments = new ArrayList<>();
private boolean isHtml = false;
public Builder(String to) {
if (to == null || to.isBlank()) {
throw new IllegalArgumentException("Recipient 'to' cannot be empty.");
}
this.to = to;
}
public Builder subject(String subject) {
this.subject = subject;
return this; // enables chaining
}
public Builder body(String body) {
this.body = body;
return this;
}
public Builder cc(String cc) {
this.cc = cc;
return this;
}
public Builder bcc(String bcc) {
this.bcc = bcc;
return this;
}
public Builder replyTo(String replyTo) {
this.replyTo = replyTo;
return this;
}
public Builder attach(String filePath) {
this.attachments.add(filePath);
return this;
}
public Builder asHtml() {
this.isHtml = true;
return this;
}
public Email build() {
return new Email(this);
}
}
3. Use it
The calling code now reads like a sentence. Every field has a name. You skip what you don’t need. No nulls required.
Checkout.java – clean, readable construction
// Minimal — only the required field
Email simple = new Email.Builder("jan@example.com")
.subject("Reset your password")
.body("Click the link below.")
.build();
// Full — every optional field used
Email full = new Email.Builder("jan@example.com")
.subject("Your invoice is ready")
.body("<h1>Invoice #1042</h1><p>Due: 2025-06-01</p>")
.cc("accounting@example.com")
.replyTo("billing@example.com")
.attach("/invoices/1042.pdf")
.asHtml()
.build();
// Can't forget 'to' — Builder constructor enforces it at compile time
// new Email.Builder(null).build(); // throws IllegalArgumentException
Why This Actually Works
Named parameters without language support. Java doesn’t have named parameters like Python or Kotlin. The Builder pattern emulates them through fluent setters, each method call is self-documenting. .cc("accounting@...") is unambiguous. The fourth null in a constructor is not.
Required vs optional, enforced by design. Putting required fields in the Builder’s constructor means the compiler catches missing data before the code ever runs. Optional fields default to sane values. You can’t accidentally build an Email without a recipient.
Immutability for free. Once build() is called, the resulting Email object is frozen. All fields are final, there are no setters, and no other code path can construct it. Safe to pass around, safe to cache, safe to use across threads.
Bonus: This Is How Query Builders Work Under the Hood
Ever used JPA Criteria API, jOOQ, or QueryDSL and wondered how they chain methods to build SQL? It’s the same pattern. Here’s a simplified version:
QueryBuilder.java – same pattern, different domain
public class QueryBuilder {
private String table;
private final List<String> conditions = new ArrayList<>();
private String orderBy;
private int limit = -1;
public QueryBuilder from(String table) {
this.table = table;
return this;
}
public QueryBuilder where(String condition) {
this.conditions.add(condition);
return this;
}
public QueryBuilder orderBy(String column) {
this.orderBy = column;
return this;
}
public QueryBuilder limit(int n) {
this.limit = n;
return this;
}
public String build() {
StringBuilder sql = new StringBuilder("SELECT * FROM " + table);
if (!conditions.isEmpty()) {
sql.append(" WHERE ").append(String.join(" AND ", conditions));
}
if (orderBy != null) sql.append(" ORDER BY ").append(orderBy);
if (limit > 0) sql.append(" LIMIT ").append(limit);
return sql.toString();
}
}
// Usage
String query = new QueryBuilder()
.from("orders")
.where("status = 'PENDING'")
.where("amount > 100")
.orderBy("created_at")
.limit(20)
.build();
// Result: SELECT * FROM orders WHERE status = 'PENDING'
// AND amount > 100 ORDER BY created_at LIMIT 20
The principle is identical: accumulate state through chained calls, construct the final result in build(). Libraries like jOOQ do exactly this, just with a lot more type safety and SQL dialect awareness on top.
When to Use It and When Not To
Reach for Builder when:
- A constructor has more than three or four parameters.
- Several of those parameters are optional with sensible defaults.
- You want the resulting object to be immutable.
- You’re building complex objects step by step where order might matter.
Leave it on the shelf when:
- The object has two or three fields – a constructor is perfectly fine.
- All fields are required and there’s nothing optional to manage.
- You’re in a framework that already handles this (Lombok’s
@Builder, records).
Speaking of Lombok – in production Java you’ll rarely write a Builder by hand because
@Buildergenerates it for you. But knowing what’s generated is the difference between using a tool and understanding it.
Wrapping Up
The Builder pattern is one of those things that feels like extra work until the first time a teammate reads your code and doesn’t have to open the constructor signature to understand what’s being built. Named steps, enforced requirements, immutable result – it’s a small investment that pays back every time someone reads that code.
Next up: Factory Method and how to decouple object creation from the code that uses objects, and why that becomes critical the moment you have more than one environment to deploy to.