Builder Pattern
Construct complex C++ objects step-by-step with fluent interfaces. Use type-state builders to enforce mandatory fields at compile time.
Builder Patternsince C++11A creational pattern that separates the construction of a complex object from its representation, using a dedicated builder class with a fluent interface to configure the target object before a final build() call materializes it.
Overview
Builder is appropriate when an object has many optional parameters, non-trivial construction logic, or invariants that must hold once the object is created. The pattern predates C++11, but move semantics made it efficient: setters take by value and std::move into internal storage, eliminating redundant copies. Three variants exist in modern C++:
- Fluent builder β setters return
*thisby reference, validated inbuild(). Efficient with move semantics since C++11. - Type-state builder β template tags track which fields have been set;
build()is a constrained member available only when all required fields are present. Clean expression requires C++20 concepts. - Designated initializers β C++20 aggregate initialization with
.field = valuesyntax. No builder class needed; sufficient when there is no validation logic.
Choose type-state when a missing field is a programming error that should never reach runtime. Use the fluent variant when validation is context-dependent or involves cross-field checks that no individual setter can see.
Examples
Fluent Builder with Cross-Field Validation
#include <optional> // C++17
#include <stdexcept>
#include <string>
#include <vector>
class QueryBuilder {
std::string table_;
std::vector<std::string> columns_{"*"};
std::string where_;
std::optional<size_t> limit_; // std::optional: C++17
std::optional<size_t> offset_;
public:
QueryBuilder& from(std::string table) {
table_ = std::move(table);
return *this;
}
QueryBuilder& select(std::vector<std::string> cols) {
columns_ = std::move(cols);
return *this;
}
QueryBuilder& where(std::string condition) {
where_ = std::move(condition);
return *this;
}
QueryBuilder& limit(size_t n) { limit_ = n; return *this; }
QueryBuilder& offset(size_t n) { offset_ = n; return *this; }
[[nodiscard]] std::string build() const { // [[nodiscard]]: C++17
if (table_.empty())
throw std::logic_error{"QueryBuilder: from() is required"};
if (offset_ && !limit_)
throw std::logic_error{"QueryBuilder: offset() requires limit()"};
std::string q = "SELECT ";
for (size_t i = 0; i < columns_.size(); ++i) {
if (i > 0) q += ", ";
q += columns_[i];
}
q += " FROM " + table_;
if (!where_.empty()) q += " WHERE " + where_;
if (limit_) q += " LIMIT " + std::to_string(*limit_);
if (offset_) q += " OFFSET " + std::to_string(*offset_);
return q;
}
};
// "SELECT id, name, email FROM users WHERE active = 1 LIMIT 20 OFFSET 40"
std::string sql = QueryBuilder{}
.from("users")
.select({"id", "name", "email"})
.where("active = 1")
.limit(20)
.offset(40)
.build();The cross-field rule β offset without limit is rejected β cannot be checked in any individual setter because neither setter knows what the other will do. All such invariants belong in build().
Type-State Builder (Mandatory Fields at Compile Time)
When a field is unconditionally required, a runtime exception is the wrong tool. Type-state builders encode "has this field been set?" as a template parameter, making build() conditionally available only once all required fields have been supplied.
The key implementation detail: inherit from a shared state struct rather than copying each field manually when transitioning between specializations.
#include <concepts> // C++20
#include <string>
namespace detail {
struct ConnState {
std::string host;
uint16_t port {};
int timeout{5000};
};
}
struct HasHost {}; struct NoHost {};
struct HasPort {}; struct NoPort {};
template <typename H, typename P>
class ConnectionBuilder : detail::ConnState {
explicit ConnectionBuilder(detail::ConnState s)
: detail::ConnState{std::move(s)} {}
// Allow all specializations to construct each other
template <typename, typename> friend class ConnectionBuilder;
public:
ConnectionBuilder() = default;
// &&-qualified: the old builder is consumed; prevents reuse after transition
[[nodiscard]] ConnectionBuilder<HasHost, P> host(std::string h) && {
this->host = std::move(h);
return ConnectionBuilder<HasHost, P>{
static_cast<detail::ConnState&&>(*this)
};
}
[[nodiscard]] ConnectionBuilder<H, HasPort> port(uint16_t p) && {
this->port = p;
return ConnectionBuilder<H, HasPort>{
static_cast<detail::ConnState&&>(*this)
};
}
[[nodiscard]] ConnectionBuilder timeout(int ms) && {
this->timeout = ms;
return std::move(*this);
}
struct Connection {
std::string host;
uint16_t port;
int timeout_ms;
};
// C++20 requires clause: build() exists only when both tags are "Has*"
[[nodiscard]] Connection build() &&
requires std::same_as<H, HasHost> && std::same_as<P, HasPort>
{
return {std::move(this->host), this->port, this->timeout};
}
};
using ConnBuilder = ConnectionBuilder<NoHost, NoPort>;
// OK β both mandatory fields supplied
auto conn = ConnBuilder{}
.host("db.internal")
.port(5432)
.timeout(3000)
.build();
// Compile error: no matching function for call to 'build()'
// ConnBuilder{}.host("db.internal").build();The static_cast<ConnState&&>(*this) in each transition moves all accumulated state into the new specialization without listing every field. The && qualifiers ensure the source builder is consumed, preventing accidental reuse of a partially-moved-from instance.
The requires clause produces a diagnostic that names the unsatisfied constraint rather than a cascading template error β a significant improvement over pre-C++20 approaches that used static_assert or SFINAE.
Designated Initializers as a Lightweight Alternative (C++20)
For aggregates with no construction logic or invariants, C++20 designated initializers achieve the same call-site readability without any builder class:
struct HttpOptions { // aggregate: no user-provided constructor
std::string url;
std::string method = "GET";
int timeout = 30;
bool follow = true;
};
auto opts = HttpOptions{
.url = "https://api.example.com/data",
.method = "POST",
.timeout = 10,
// .follow omitted β takes default value true
};Fields must be initialized in declaration order at the call site; the compiler enforces this. Omitted fields take their in-class defaults. They cannot run validation or enforce invariants β reach for a builder as soon as those requirements appear.
Best Practices
[[nodiscard]]onbuild()(C++17): callingbuild()and discarding the result is nearly always a bug. The attribute turns it into a warning.- Validate in
build(), not in setters: cross-field constraints require the complete picture. Centralizing all validation in one function makes the invariants easy to audit. &&-qualify setters in type-state builders: prevents storing an intermediate builder and calling setters on it after it has been transitioned to a new specialization.- Inherit from a state struct: eliminates per-field boilerplate when shifting between template specializations and keeps the transition functions robust when new fields are added.
- Prefer designated initializers for simple aggregates: adding a builder class for a struct with no invariants is unnecessary complexity.
Common Pitfalls
operator T() as an implicit conversion from the builder: it triggers silently wherever T is expected, bypassing any validation in build(). Always use an explicit named conversion function.
Calling build() more than once on an rvalue-qualified builder: after build() moves from internal state, the builder holds moved-from objects. Making build() &&-qualified converts the second call into a compile error:
// &&-qualified build() makes reuse a hard error
[[nodiscard]] ServerConfig build() && { return std::move(cfg_); }
auto b = ServerConfigBuilder{}.host("x").port(443);
auto c1 = std::move(b).build(); // OK
auto c2 = std::move(b).build(); // build() on moved-from object β UB if not &&-qualifiedBoolean or integer template parameters for type-state tags: tag structs like HasHost / NoHost are zero-sized (EBO applies) and appear by name in compiler diagnostics. template <bool HasHost, bool HasPort> produces harder-to-read error messages and conflates unrelated states into a single dimension.
Omitting [[nodiscard]] on transitioning setters in type-state builders: if a caller writes builder.host("x") without assigning the result, the transition is silently discarded and the compiler gives no warning. Mark every &&-qualified setter [[nodiscard]].
Two-Phase Builder: Separation of Construction from Product
The GoF builder pattern is most precisely expressed as two distinct classes: a mutable Builder that accumulates configuration, and an immutable Product that is the result. The product's constructor is private; the builder is its friend. This enforces a single construction path and keeps the product's invariants airtight.
#include <expected> // C++23
#include <string>
#include <stdexcept>
class ServerConfig {
public:
struct Builder; // forward-declare nested builder
[[nodiscard]] const std::string& host() const { return host_; }
[[nodiscard]] uint16_t port() const { return port_; }
[[nodiscard]] int backlog() const { return backlog_; }
[[nodiscard]] bool tls() const { return tls_; }
private:
friend struct Builder;
// Private constructor: only Builder may call this
ServerConfig(std::string host, uint16_t port, int backlog, bool tls)
: host_{std::move(host)}, port_{port}, backlog_{backlog}, tls_{tls} {}
std::string host_;
uint16_t port_ {};
int backlog_ {128};
bool tls_ {false};
};
struct ServerConfig::Builder {
Builder& host(std::string h) { host_ = std::move(h); return *this; }
Builder& port(uint16_t p) { port_ = p; return *this; }
Builder& backlog(int b) { backlog_ = b; return *this; }
Builder& tls(bool t = true) { tls_ = t; return *this; }
// Returns std::expected so callers can handle validation without exceptions
[[nodiscard]]
std::expected<ServerConfig, std::string> build() const { // C++23
if (host_.empty())
return std::unexpected{"host must not be empty"};
if (port_ == 0)
return std::unexpected{"port must be non-zero"};
if (tls_ && port_ == 80)
return std::unexpected{"TLS on port 80 is almost certainly wrong"};
return ServerConfig{host_, port_, backlog_, tls_};
}
private:
std::string host_;
uint16_t port_ {};
int backlog_ {128};
bool tls_ {false};
};
// Call site
auto result = ServerConfig::Builder{}
.host("api.example.com")
.port(443)
.tls()
.build();
if (result) {
launch(*result);
} else {
log_error(result.error());
}std::expected (C++23) is the right return type when invalid configuration is a recoverable condition β a misconfigured service in a larger system should propagate an error, not unwind the stack. Use throw from build() only when the programming contract makes a bad config truly unrecoverable (for example, a unit-test harness where the test is meaningless without a valid object).
The nested Builder pattern also communicates ownership intent: ServerConfig::Builder at a glance tells readers this builder exists solely to produce ServerConfig objects, not as a general-purpose configuration record.
Fluent Interface: Reference vs. Rvalue Overloads
The standard fluent setter returns *this by lvalue reference:
Builder& setHost(std::string h) & {
host_ = std::move(h);
return *this;
}This is the right choice when callers store the builder as a named variable and configure it in stages β across loops, conditionals, or function boundaries. The builder can be reused to produce multiple products.
The &&-qualified overload has a different purpose: it allows the compiler to elide copies when a temporary builder is chained on one expression:
// &&-qualified overload: consumed and returned as rvalue
Builder&& setHost(std::string h) && {
host_ = std::move(h);
return std::move(*this);
}Providing both overloads is idiomatic when you want to serve both usage patterns without forcing callers to choose:
class PipelineBuilder {
std::string source_;
std::string sink_;
int workers_ {1};
public:
// lvalue overloads β for named variables
PipelineBuilder& source(std::string s) & { source_ = std::move(s); return *this; }
PipelineBuilder& sink(std::string s) & { sink_ = std::move(s); return *this; }
PipelineBuilder& workers(int n) & { workers_ = n; return *this; }
// rvalue overloads β for temporary chaining
PipelineBuilder&& source(std::string s) && { source_ = std::move(s); return std::move(*this); }
PipelineBuilder&& sink(std::string s) && { sink_ = std::move(s); return std::move(*this); }
PipelineBuilder&& workers(int n) && { workers_ = n; return std::move(*this); }
[[nodiscard]] Pipeline build() && {
return Pipeline{std::move(source_), std::move(sink_), workers_};
}
};
// Named-variable path (lvalue overloads)
PipelineBuilder b;
b.source("kafka://events");
if (use_db) b.sink("postgres://results");
else b.sink("file:///tmp/out");
auto p = std::move(b).build();
// Temporary path (rvalue overloads, single expression)
auto q = PipelineBuilder{}
.source("kafka://events")
.sink("redis://cache")
.workers(4)
.build();The rvalue overload path involves no extra copies because each call returns the same object as an rvalue; move elision applies. The lvalue overload path avoids creating temporaries and suits configuration that is assembled conditionally. Having both means the API is not opinionated about the caller's control flow.
Inner Class Builder and Friend Access
The canonical GoF C++ implementation nests the builder inside the product as a public inner class. The builder declares the product as a friend so it can reach the private constructor:
class HttpClient {
public:
class Builder {
public:
Builder& baseUrl(std::string url) { base_url_ = std::move(url); return *this; }
Builder& timeout(int ms) { timeout_ = ms; return *this; }
Builder& userAgent(std::string ua) { user_agent_ = std::move(ua); return *this; }
Builder& followRedirects(bool f=true) { follow_ = f; return *this; }
[[nodiscard]] HttpClient build() {
if (base_url_.empty())
throw std::logic_error{"HttpClient::Builder: baseUrl() is required"};
// Invoke private constructor via friend access
return HttpClient{
std::move(base_url_),
timeout_,
std::move(user_agent_),
follow_
};
}
private:
std::string base_url_;
int timeout_ {5000};
std::string user_agent_ {"bigcpp/1.0"};
bool follow_ {true};
};
// Public getters only β no public constructor
[[nodiscard]] const std::string& baseUrl() const { return base_url_; }
[[nodiscard]] int timeout() const { return timeout_; }
private:
friend class Builder;
HttpClient(std::string url, int to, std::string ua, bool f)
: base_url_{std::move(url)}, timeout_{to},
user_agent_{std::move(ua)}, follow_{f} {}
std::string base_url_;
int timeout_;
std::string user_agent_;
bool follow_;
};
// The only way to construct an HttpClient:
auto client = HttpClient::Builder{}
.baseUrl("https://api.example.com")
.timeout(10000)
.userAgent("my-service/2.0")
.build();The friend declaration inside HttpClient grants Builder access to the private constructor without exposing it to any other code. This is the pattern most C++ style guides mean when they refer to the "GoF builder" in C++: the product class is construction-opaque except through its builder, and the builder is scoped to signal that relationship unambiguously.
C++20 Designated Initializers: When to Skip the Builder
For aggregate types β structs with no user-provided constructors, no private members, no base classes with non-trivial constructors β C++20 designated initializers provide builder-level readability at the call site with zero boilerplate:
// Aggregate: all members public, no user-provided constructor
struct ServerConfig {
std::string host;
uint16_t port = 8080;
int timeout_ms = 5000;
int backlog = 128;
bool tls = false;
std::string cert_path;
};
// Call site reads like named arguments
auto cfg = ServerConfig{
.host = "api.example.com",
.port = 443,
.tls = true,
.cert_path = "/etc/ssl/api.pem",
// timeout_ms and backlog use in-class defaults
};Designated initializers enforce declaration order: the compiler rejects initializers that appear out of the order in which fields are declared in the struct. This makes adding a new field to ServerConfig safe β the compiler flags every call site that needs to be updated if the new field comes before an existing designated initializer.
When designated initializers are sufficient:
- No validation is needed (any combination of field values produces a valid object).
- All fields are value types or standard library types β no heap allocation with failure modes.
- The type is
constexpr-capable and you want to construct it at compile time.
When a full builder is necessary:
- Cross-field constraints must be checked (
tls = trueandport = 80should be rejected). - Construction involves resource acquisition β opening a file, connecting a socket, allocating a buffer.
- The type is not an aggregate (it has a base class, virtual functions, or a private section that encapsulates invariants).
- You need to support incremental configuration across control flow branches before committing to construction.
Thread-Safe Builder: Immutable Product via shared_ptr
A builder is inherently single-threaded: it accumulates mutable state and is not designed to be shared across threads during configuration. The product, however, can be made immutable and shared cheaply:
#include <memory>
#include <mutex>
#include <string>
// Fully immutable product β safe to share across threads without synchronization
class CacheConfig {
public:
class Builder;
[[nodiscard]] size_t maxEntries() const { return max_entries_; }
[[nodiscard]] int ttlSeconds() const { return ttl_seconds_; }
[[nodiscard]] const std::string& eviction() const { return eviction_; }
private:
friend class Builder;
CacheConfig(size_t max, int ttl, std::string ev)
: max_entries_{max}, ttl_seconds_{ttl}, eviction_{std::move(ev)} {}
const size_t max_entries_;
const int ttl_seconds_;
const std::string eviction_;
};
class CacheConfig::Builder {
public:
Builder& maxEntries(size_t n) { max_entries_ = n; return *this; }
Builder& ttlSeconds(int s) { ttl_seconds_ = s; return *this; }
Builder& eviction(std::string p) { eviction_ = std::move(p); return *this; }
// Returns shared_ptr<const Product> β callers share ownership,
// but the object itself is never mutated after construction
[[nodiscard]]
std::shared_ptr<const CacheConfig> build() const {
if (max_entries_ == 0)
throw std::logic_error{"maxEntries must be > 0"};
// std::make_shared: single allocation for control block + object
return std::make_shared<const CacheConfig>(
max_entries_, ttl_seconds_, eviction_
);
}
private:
size_t max_entries_ {1024};
int ttl_seconds_ {300};
std::string eviction_ {"lru"};
};
// Build once on the main thread
auto config = CacheConfig::Builder{}
.maxEntries(8192)
.ttlSeconds(600)
.eviction("lfu")
.build();
// Distribute to worker threads β no locking needed for reads
// because const members cannot be mutated through any interface
std::thread t1{[config]{ run_worker(config); }};
std::thread t2{[config]{ run_worker(config); }};Three properties combine to make this safe:
constmembers:CacheConfigusesconstdata members, so any attempt to mutate them after construction is a compile error.shared_ptr<const T>: shared ownership of aconstobject; reference counting is atomic, and no thread can modify the object through this pointer.- Builder is local: the builder itself is not shared. It is used on one thread, produces the product, and is then discarded. There is no window in which a second thread sees a partially-configured builder.
This pattern appears frequently in configuration systems where a startup phase builds an immutable config that is then injected into many components running on separate threads.
See Also
- RAII β builder is often the mechanism for assembling constructor arguments for an RAII guard before committing to resource acquisition
- Abstract Factory β select among concrete types at runtime; builder configures a single type
- Designated Initializers β C++20 aggregate alternative for simple cases
- Concepts and
requiresβ the constraint mechanism that makes type-statebuild()readable