System Design (LLD) - Builder Design Pattern: Classic, Director, & Step Builder
The Builder Design Pattern is a creational design pattern used to construct complex objects step by step. In real-world production systems, instantiating complex objects using standard constructors or setters often leads to maintainability issues, parameter confusion, and invalid object states. The Builder Pattern decouples the construction logic of a target object from its internal representation, providing clean, fluent, and safe object creation workflows.
1. The Core Problem: Constructing Complex Objects
Consider a scenario where a client application needs to instantiate an object representing an HTTP Request (HttpRequest). An HTTP request in modern software architectures comprises several mandatory and optional configuration parameters:
- URL (Mandatory): Target endpoint string (e.g.,
https://api.example.com/v1/resource) - Method (Mandatory): HTTP method (e.g.,
GET,POST,PUT,DELETE) - Headers (Optional/Mandatory depending on call): Map of key-value pairs (e.g.,
Content-Type: application/json) - Query Parameters (Optional): Map of key-value URL parameters
- Body (Optional): Request payload string or JSON string
- Timeout (Optional/Defaulted): Request timeout threshold in seconds (e.g., 30s or 60s)
Naive Implementation Approach
Without a creational pattern, developers typically rely on traditional constructors or setters:
// Naive Instantiation via Constructor
HttpRequest* request = new HttpRequest(
"https://api.example.com/data",
"POST",
headersMap,
queryParamsMap,
"{\"key\":\"value\"}",
60
);
While this works for trivial classes, it rapidly degrades as object complexity increases.
2. Four Pitfalls of Naive Object Construction
Constructing complex domain objects using direct constructors or public setters introduces four critical architecture-level anti-patterns:
1. Telescoping Constructor Problem (Constructor Overloading Overkill)
To support varying combinations of optional parameters, developers create multiple overloaded constructors. This is known as Constructor Telescoping:
class HttpRequest {
public:
HttpRequest(string url);
HttpRequest(string url, string method);
HttpRequest(string url, string method, int timeout);
HttpRequest(string url, string method, map<string, string> headers);
HttpRequest(string url, string method, map<string, string> headers, map<string, string> queryParams);
HttpRequest(string url, string method, map<string, string> headers, map<string, string> queryParams, string body, int timeout);
// Exponential growth of constructors as parameters increase!
};
- Impact: Maintenance becomes exponential. Adding a single new parameter requires modifying or adding dozens of constructor variations.
2. Object Mutability (Loss of Immutability)
To avoid telescoping constructors, developers often switch to a zero-argument constructor combined with public setter methods (setUrl(), setMethod(), setBody()).
- Impact: The resulting object is fully mutable. Once created, any caller across the codebase can modify critical fields (e.g., changing the request URL mid-flight), introducing unexpected side effects in multi-threaded environments.
3. Inconsistent / Partially Initialized State Problem
When relying on setters, an object passes through a window of time where it exists in memory but is incomplete or invalid before all mandatory setters are executed.
HttpRequest request;
request.setMethod("POST");
// Developer forgets to call request.setUrl(...)!
request.execute(); // FAILS AT RUNTIME!
- Impact: The compiler cannot verify if mandatory fields were supplied. Failures move from compile-time to catastrophic runtime exceptions in production.
4. Scattered Validations
Because the target object cannot validate its parameters until execution time, parameter validation logic ends up scattered across operational methods (execute()) or duplicated across caller services.
Note: A strong design rule in LLD: Catch errors at compile-time whenever possible. Red squiggly lines in your IDE during compilation are your best friend; runtime crashes in production are your worst enemy.
3. Core Mental Model & Conceptual Workflow
The Builder Pattern resolves these four pitfalls by introducing a dedicated Builder component that handles parameter collection, state assembly, and runtime validations before instantiating the immutable Target Class.
Sequential Architectural Execution Flow
4. Solution 1 — Classic Builder Design Pattern
In the Classic Builder Pattern, parameter collection methods use a Fluent Interface pattern (method chaining) where each configuration method sets an internal field and returns this (the builder reference).
Key Architectural Traits
- Private Target Constructor: The target class (
HttpRequest) hides its constructor, forcing clients to route creation through the Builder. - Friend / Inner Class Access: The Builder class is granted access to construct the target class (via C++
friend classdeclaration or Java static inner class structures). - Intermediary Methods vs. Terminal Method:
- Intermediary Methods (
withUrl(),withMethod()): Accept data, update internal builder state, andreturn *this. - Terminal Method (
build()): Validates accumulated state and instantiates the immutable target class object.
- Intermediary Methods (
Implementation Code (C++ Implementation)
#include <iostream>
#include <string>
#include <map>
#include <stdexcept>
using namespace std;
// Forward declaration
class HttpRequestBuilder;
// Target Class: Fully Immutable once created
class HttpRequest {
private:
string url;
string method;
map<string, string> headers;
map<string, string> queryParams;
string body;
int timeout;
// Private Constructor prevents direct 'new' instantiation by clients
HttpRequest(string url, string method, map<string, string> headers,
map<string, string> queryParams, string body, int timeout)
: url(url), method(method), headers(headers),
queryParams(queryParams), body(body), timeout(timeout) {}
// Declare Builder as a friend to grant access to private constructor
friend class HttpRequestBuilder;
public:
void execute() const {
cout << "\n--- Executing HTTP Request ---" << endl;
cout << "URL: " << url << endl;
cout << "Method: " << method << endl;
cout << "Timeout: " << timeout << "s" << endl;
if (!body.empty()) cout << "Body: " << body << endl;
cout << "Request executed successfully!\n" << endl;
}
};
// Builder Class
class HttpRequestBuilder {
private:
string url;
string method = "GET"; // Default value
map<string, string> headers;
map<string, string> queryParams;
string body = "";
int timeout = 30; // Default value
public:
HttpRequestBuilder() {}
// Fluent Setter Methods (Intermediary Methods)
HttpRequestBuilder& withUrl(const string& url) {
this->url = url;
return *this; // Returns reference to builder for method chaining
}
HttpRequestBuilder& withMethod(const string& method) {
this->method = method;
return *this;
}
HttpRequestBuilder& addHeader(const string& key, const string& value) {
this->headers[key] = value;
return *this;
}
HttpRequestBuilder& addQueryParam(const string& key, const string& value) {
this->queryParams[key] = value;
return *this;
}
HttpRequestBuilder& withBody(const string& body) {
this->body = body;
return *this;
}
HttpRequestBuilder& withTimeout(int timeout) {
this->timeout = timeout;
return *this;
}
// Terminal Method
HttpRequest build() {
// Centralized Validations
if (this->url.empty()) {
throw runtime_error("Validation Error: URL cannot be empty!");
}
if (this->method == "POST" && this->body.empty()) {
throw runtime_error("Validation Error: POST request requires a payload body!");
}
// Return concrete target object
return HttpRequest(url, method, headers, queryParams, body, timeout);
}
};
// Client Usage
int main() {
try {
// Fluent Method Chaining
HttpRequest request = HttpRequestBuilder()
.withUrl("https://api.example.com/v1/users")
.withMethod("POST")
.addHeader("Content-Type", "application/json")
.withBody("{\"name\": \"Alice\"}")
.withTimeout(60)
.build();
request.execute();
}
catch (const exception& e) {
cerr << e.what() << endl;
}
return 0;
}
5. Solution 2 — Builder Pattern with Director
While the Classic Builder solves mutability and validation issues, clients may still repeat identical configuration code across the application when creating standard, pre-configured object representations (e.g., standard GET request presets or standard JSON POST request presets).
The Role of the Director
The Director class sits on top of the Builder. It orchestrates pre-defined, standardized construction recipes using a builder instance.
Implementation Code (Director Integration)
class HttpRequestDirector {
public:
// Recipe 1: Pre-configured Standard GET Request
static HttpRequest createGetRequest(const string& url) {
return HttpRequestBuilder()
.withUrl(url)
.withMethod("GET")
.withTimeout(30)
.build();
}
// Recipe 2: Pre-configured Standard JSON POST Request
static HttpRequest createJsonPostRequest(const string& url, const string& jsonBody) {
return HttpRequestBuilder()
.withUrl(url)
.withMethod("POST")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/json")
.withBody(jsonBody)
.withTimeout(60)
.build();
}
};
// Client Usage with Director
int main() {
// Client does not need to manually configure headers or methods for standard requests
HttpRequest getReq = HttpRequestDirector::createGetRequest("https://api.example.com/items");
getReq.execute();
HttpRequest postReq = HttpRequestDirector::createJsonPostRequest(
"https://api.example.com/items",
"{\"item\": \"Laptop\"}"
);
postReq.execute();
return 0;
}
6. Solution 3 — Step Builder Pattern: Enforcing Compile-Time Sequence
The Flaw in Classic Builder
In the Classic Builder, intermediary methods return the entire Builder interface. This means:
- Clients can call setup methods out of order (e.g., calling
.withBody()before.withUrl()). - Clients can accidently skip mandatory fields altogether and only discover the missing parameters at runtime when
.build()throws an exception.
Intuitive Real-World Analogies
Real-Life Analogy 1: Assembling a Custom Pizza
When ordering a custom pizza, a restaurant enforcing step-by-step workflow will not allow you to pick toppings before selecting your crust:
Real-Life Analogy 2: Building a House
You cannot install the roof before laying down the foundation and building the supporting walls:
How Step Builder Works
The Step Builder Pattern uses Interface Chaining (or abstract class chaining). Instead of returning the full Builder instance, each step method returns a specific interface exposing only the next allowed step in the sequence.
This enforces strict sequence enforcement directly in the IDE via code auto-completion and prevents code from compiling if a mandatory step is missing!
Implementation Code (Step Builder Pattern)
#include <iostream>
#include <string>
#include <map>
#include <stdexcept>
using namespace std;
class HttpRequest; // Forward declaration
// --- Step Interfaces ---
class MethodStep; // Forward declaration
class UrlStep {
public:
virtual MethodStep& withUrl(const string& url) = 0;
};
class HeaderStep; // Forward declaration
class MethodStep {
public:
virtual HeaderStep& withMethod(const string& method) = 0;
};
class OptionalStep; // Forward declaration
class HeaderStep {
public:
virtual OptionalStep& withHeader(const string& key, const string& value) = 0;
};
class OptionalStep {
public:
virtual OptionalStep& withBody(const string& body) = 0;
virtual OptionalStep& withTimeout(int timeout) = 0;
virtual HttpRequest build() = 0; // Final terminal build
};
// Target Domain Class
class HttpRequest {
private:
string url;
string method;
string headerKey, headerVal;
string body;
int timeout;
HttpRequest(string url, string method, string hKey, string hVal, string body, int timeout)
: url(url), method(method), headerKey(hKey), headerVal(hVal), body(body), timeout(timeout) {}
friend class StepBuilder;
public:
void execute() {
cout << "StepBuilder Request Executed: " << method << " " << url
<< " [Header: " << headerKey << "=" << headerVal << "]" << endl;
}
};
// Concrete Step Builder implementing ALL Step Interfaces via Multiple Inheritance
class StepBuilder : public UrlStep, public MethodStep, public HeaderStep, public OptionalStep {
private:
string url;
string method;
string headerKey, headerVal;
string body = "";
int timeout = 30;
StepBuilder() {} // Private Constructor
public:
// Entry Point
static UrlStep& start() {
return *(new StepBuilder());
}
// Step 1 implementation
MethodStep& withUrl(const string& url) override {
this->url = url;
return *this; // Returned as MethodStep& reference
}
// Step 2 implementation
HeaderStep& withMethod(const string& method) override {
this->method = method;
return *this; // Returned as HeaderStep& reference
}
// Step 3 implementation
OptionalStep& withHeader(const string& key, const string& value) override {
this->headerKey = key;
this->headerVal = value;
return *this; // Returned as OptionalStep& reference
}
// Optional Steps implementation
OptionalStep& withBody(const string& body) override {
this->body = body;
return *this;
}
OptionalStep& withTimeout(int timeout) override {
this->timeout = timeout;
return *this;
}
HttpRequest build() override {
return HttpRequest(url, method, headerKey, headerVal, body, timeout);
}
};
// Client Usage
int main() {
// Mandatory Step Sequence Enforced at Compile Time:
// start() -> withUrl() -> withMethod() -> withHeader() -> [Optional withBody/withTimeout] -> build()
HttpRequest request = StepBuilder::start()
.withUrl("https://api.example.com/v1/orders") // Returns MethodStep
.withMethod("POST") // Returns HeaderStep
.withHeader("Authorization", "Bearer token") // Returns OptionalStep
.withBody("{\"orderId\": 101}") // Returns OptionalStep
.build(); // Constructs Object
request.execute();
// COMPILE-TIME ERROR EXAMPLE:
// If you try: StepBuilder::start().withMethod("GET")
// Compiler Error: Class 'UrlStep' has no member 'withMethod'.
return 0;
}
7. Comparative Analysis: Classic vs. Director vs. Step Builder
8. Real-World Architectural Edge Cases & Debugging Mindset
1. Thread Safety & Concurrency
- Target Object: Once built, the target domain object (
HttpRequest) has no setters, making it inherently thread-safe and shareable across threads without lock synchronization. - Builder Instances: Builder instances are stateful and transient. Do not share a single builder instance across concurrent worker threads. Always create a fresh builder per invocation thread.
2. Inner Static Class Structural Design (Java Benchmark)
In languages like Java, the Builder is commonly implemented as a public static class inside the Target class:
public class HttpRequest {
private final String url; // immutable
private HttpRequest(Builder builder) {
this.url = builder.url;
}
public static class Builder {
private String url;
public Builder withUrl(String url) { this.url = url; return this; }
public HttpRequest build() { return new HttpRequest(this); }
}
}
- This cleanly bypasses language visibility restrictions without needing external C++
frienddeclarations.
3. Error Handling Strategy: Runtime Exception Placement
- Intermediary Validations: Throw early inside intermediary builder setters (e.g., throwing
IllegalArgumentExceptioninwithTimeout(int t)ift < 0). - Inversion Invariant Validations: Perform cross-field checks inside the
.build()method (e.g., verifying that ifmethod == "POST",bodymust not be empty).
9. Summary and Key Takeaways
- Solves Telescoping Constructors: The Builder Pattern eliminates the need for combinatorial constructor overloading when objects contain numerous mandatory/optional parameters.
- Guarantees Object Immutability: By hiding the target class constructor and avoiding public setter methods, constructed objects remain fully immutable and thread-safe post-instantiation.
- Prevents Inconsistent States: Objects are instantiated only when
.build()is executed, ensuring no caller ever accesses partially initialized instances. - Centralizes Validation Logic: Parameter assertions and constraint checks are unified inside the builder’s
.build()method rather than spread across caller services. - Fluent Interface API: The pattern uses method chaining (
return this) to dramatically increase code readability and maintainability. - Director Provides Standardized Recipes: Integrating a
Directorcomponent allows systems to encapsulate reusable object templates (e.g., standard JSON POST requests) cleanly. - Step Builder Eliminates Out-of-Order Assembly: By returning specialized step interfaces, the
Step Builderleverages compiler checks and IDE auto-completion to strictly enforce required execution sequences.