Skip to content
C++
Idiom
since C++11
Intermediate

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++11

A 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 *this by reference, validated in build(). 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 = value syntax. 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

cpp
#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.

cpp
#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:

cpp
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]] on build() (C++17): calling build() 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:

cpp
// &&-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 &&-qualified

Boolean 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-state build() readable