How to Implement a Builder Pattern with Derive Macros

Implement a Builder pattern in Rust by adding the buildstructor crate and using the #[derive(Builder)] attribute on your struct.

The pain of twelve arguments

You are writing a configuration struct for a database client. It needs a host, a port, a username, a password, a timeout, a retry policy, an SSL mode, a custom serializer, a connection pool size, and a feature flag. Writing a constructor with twelve arguments feels wrong. The order of arguments is arbitrary. Callers have to look up the signature every time. Passing None for every optional field clutters the call site. You want code that reads like a sentence: .host("db").port(5432).ssl(true).build().

That is the builder pattern. It turns a chaotic list of parameters into a fluent, self-documenting chain of method calls. In Rust, implementing a builder by hand requires writing a separate builder struct, implementing setter methods that return self, and handling the final construction. The boilerplate dwarfs the actual logic. Derive macros eliminate the tedium. They scan your struct and generate the entire builder implementation automatically.

How the builder pattern works

A builder is a separate type that accumulates configuration before creating the final object. Think of it like a menu at a pizza place. You do not hand the chef a raw list of dough, sauce, and pepperoni and hope they assemble it correctly. You use the menu. You select the crust. You pick the toppings. The menu collects your choices. When you submit the order, the kitchen receives a complete, validated specification.

In Rust, the builder is that menu. It is a struct generated alongside your target struct. The builder holds mutable fields for every option. Each setter method updates one field and returns the builder itself. This return value enables chaining. The build() method consumes the builder, checks that all required fields are present, and constructs the final struct. The builder enforces correctness at compile time. If you skip a required field, the code does not compile.

Minimal example

The buildstructor crate provides a derive macro that generates a builder from a struct definition. Add the dependency to your Cargo.toml and apply the #[derive(Builder)] attribute.

use buildstructor::Builder;

/// A simple rectangle with width and height.
#[derive(Builder, Debug, PartialEq)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    // The macro generates RectangleBuilder.
    // .width() and .height() return the builder, enabling chaining.
    let rect = Rectangle::builder()
        .width(30)
        .height(50)
        .build();

    assert_eq!(rect.width, 30);
    assert_eq!(rect.height, 50);
}

The macro scans Rectangle. It sees two fields. It generates a RectangleBuilder struct with mutable width and height fields. It generates a builder() associated function on Rectangle that creates a fresh builder. It generates width() and height() methods on the builder. Each method takes self, updates the field, and returns self. Finally, it generates a build() method that extracts the fields and returns a Rectangle.

What happens under the hood

Derive macros are code generators. They do not add runtime overhead. The generated code is equivalent to what you would write by hand. The macro expands into a RectangleBuilder struct and an impl block. The build() method moves the fields out of the builder and into the Rectangle. Because build() takes self, it consumes the builder. You cannot call build() twice. This consumption is a feature. It prevents accidental reuse of a partially configured state.

The generated code looks roughly like this:

// Conceptual expansion. The macro generates this code for you.
struct RectangleBuilder {
    width: Option<u32>,
    height: Option<u32>,
}

impl RectangleBuilder {
    fn width(mut self, value: u32) -> Self {
        self.width = Some(value);
        self
    }

    fn height(mut self, value: u32) -> Self {
        self.height = Some(value);
        self
    }

    fn build(self) -> Rectangle {
        // The macro generates checks here.
        // If a field is None, the compiler rejects the code.
        Rectangle {
            width: self.width.expect("width is required"),
            height: self.height.expect("height is required"),
        }
    }
}

impl Rectangle {
    fn builder() -> RectangleBuilder {
        RectangleBuilder {
            width: None,
            height: None,
        }
    }
}

The builder fields are Option<T> internally. This allows the builder to track which fields have been set. The build() method unwraps the options. If an option is None, the generated code triggers a compile-time error. The macro uses techniques like const evaluation or type tricks to ensure the error appears at compile time, not runtime.

Convention aside: The community expects builder methods to return Self by value, not &mut Self. Returning &mut Self breaks the chain and forces awkward syntax. Derive macros enforce the fluent style automatically. If you write a builder by hand, follow the same convention. Return self.

Realistic configuration

Real structs have optional fields, defaults, and complex types. buildstructor supports attributes to customize the generated builder. Use #[builder(default)] to make a field optional. Use #[builder(default = value)] to provide a specific default.

use buildstructor::Builder;

/// Configuration for a server connection.
#[derive(Builder, Debug)]
struct ServerConfig {
    /// The host address. Required.
    host: String,

    /// The port number. Defaults to 0 if not set.
    #[builder(default)]
    port: u16,

    /// Connection timeout in milliseconds. Defaults to 1000.
    #[builder(default = 1000)]
    timeout_ms: u64,

    /// SSL mode. Defaults to "require".
    #[builder(default = "require".to_string())]
    ssl_mode: String,
}

fn main() {
    // Only host is required. Other fields use defaults.
    let config = ServerConfig::builder()
        .host("127.0.0.1")
        .timeout_ms(2000)
        .build();

    assert_eq!(config.port, 0);
    assert_eq!(config.timeout_ms, 2000);
    assert_eq!(config.ssl_mode, "require");
}

The #[builder(default)] attribute tells the macro to generate a setter that accepts an Option<T> or allows the field to be skipped. If you skip the field, the builder uses the default value during build(). For port, the default is u16::default(), which is 0. For timeout_ms, the default is the literal 1000. For ssl_mode, the default is the expression "require".to_string().

You can also customize the builder name or pattern. Use #[builder(name = "MyBuilder")] to change the generated struct name. Use #[builder(pattern = "owned")] to force the builder to take ownership of values instead of cloning. The macro respects visibility. Private fields generate private setter methods. Public fields generate public setters.

Convention aside: Do not wrap fields in Option<T> manually unless the domain logic requires distinguishing "not set" from "set to None". The builder handles optionality for you. If a field is optional in the builder, the struct field can be the concrete type. The builder stores the Option internally and unwraps it during construction. This keeps your public API clean.

Pitfalls and compiler errors

Derive macros generate code. If the generated code is invalid, the compiler rejects it. The error messages can be long because they point to macro expansion. Focus on the top of the error chain. The message usually explains the root cause.

If you skip a required field, the compiler rejects the code with an error about missing fields. The error points to the build() call. It lists the fields that are still uninitialized. You might see an error like "use of possibly uninitialized variable" or a custom error from the macro's validation logic. The solution is to call the missing setter or mark the field as optional with #[builder(default)].

use buildstructor::Builder;

#[derive(Builder)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    // Error: missing field `y`.
    // The compiler rejects this with an error pointing to build().
    let _p = Point::builder()
        .x(10)
        .build();
}

If you pass a value of the wrong type, you get a standard type mismatch error. The error code is E0308. The message tells you the expected type and the found type. This happens in the setter method, not in build().

use buildstructor::Builder;

#[derive(Builder)]
struct Config {
    count: u32,
}

fn main() {
    // Error[E0308]: mismatched types.
    // Expected u32, found i32.
    let _c = Config::builder()
        .count(-5)
        .build();
}

If you try to call build() twice, you get E0382 (use of moved value). The builder is consumed by the first call. This is intentional. The builder represents a single construction attempt. If you need multiple instances, create a new builder.

use buildstructor::Builder;

#[derive(Builder)]
struct Item {
    id: u32,
}

fn main() {
    let builder = Item::builder().id(1);

    let _first = builder.build();

    // Error[E0382]: use of moved value `builder`.
    // `builder` was consumed by the previous build() call.
    let _second = builder.build();
}

Another pitfall is using buildstructor on structs with complex lifetimes or generic parameters that do not implement Default. The macro generates code that assumes fields can be stored in Option<T>. If a field has a lifetime, the builder must also carry that lifetime. The macro handles this, but the generated code can become verbose. If you hit limitations, consider writing the builder by hand.

Convention aside: Use cargo expand to inspect the generated code. Run cargo install cargo-expand and then cargo expand in your project. This shows the macro output. It helps you understand why the compiler is complaining. It also reveals the exact types and methods the macro created.

When to use builders

Choosing the right construction pattern depends on the complexity of the struct and the needs of the callers. Use the builder pattern when the struct has many fields, when some fields are optional, or when you want a fluent API. Use simpler patterns when the struct is small or the configuration is straightforward.

Use buildstructor when you have a struct with many fields and want a fluent API without writing boilerplate. Use buildstructor when you need optional fields with defaults and want the macro to handle the Option logic. Use buildstructor when you want to enforce required fields at compile time with minimal code.

Use a manual builder when you need complex validation logic that spans multiple fields or requires external state during construction. Use a manual builder when the derive macro cannot express your requirements, such as custom error types or dynamic field generation. Use a manual builder when you want fine-grained control over the generated code structure.

Use a simple constructor function when the struct has few fields and the configuration is straightforward. Use a simple constructor when all fields are required and the order is well-defined. Use a simple constructor when performance is critical and you want to avoid the indirection of a builder, though the optimizer usually eliminates builder overhead.

Use serde deserialization when your configuration comes from a file or environment variables rather than code. Use serde when you need to parse JSON, YAML, or TOML. Use serde when the configuration is external and subject to change.

Use #[derive(Default)] when all fields have sensible defaults and you rarely need to override them. Use Default when the struct represents a state that can be initialized with zeros or empty values. Use Default when you want the simplest possible construction syntax.

Trust the derive macro for the boring stuff. Save your brainpower for the logic that matters. If the macro generates code that works, use it. If it gets in your way, write the builder by hand. The pattern is the same. The macro just saves you the typing.

Where to go next