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]].
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