Telex logo Telex

Rust Patterns That Matter #17: Builder Pattern

Post 17 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.

Previous: #16: Storing Closures | Next: #18: Typestate

A struct has twelve fields. Three are required. Nine have sensible defaults. The constructor has twelve parameters and callers have to remember which position is which. Or you use Default and field-by-field assignment, losing any validation. The builder pattern separates construction from representation, giving you named parameters, defaults, and validation in one clean API.

The motivation

struct ServerConfig {
    host: String,
    port: u16,
    max_connections: usize,
    timeout_secs: u64,
    tls_enabled: bool,
    cert_path: Option<String>,
    log_level: String,
}

// The telescoping constructor:
let config = ServerConfig::new(
    "0.0.0.0", 8080, 100, 30, true, Some("/etc/cert.pem"), "info",
);
// Which number is the port? Which is the timeout? Which is max connections?

This is hard to read, easy to get wrong, and impossible to extend without breaking every call site.

The pattern

A separate builder struct with chained setter methods:

struct ServerConfigBuilder {
    host: String,
    port: u16,
    max_connections: usize,
    timeout_secs: u64,
    tls_enabled: bool,
    cert_path: Option<String>,
    log_level: String,
}

impl ServerConfig {
    fn builder(host: impl Into<String>, port: u16) -> ServerConfigBuilder {
        ServerConfigBuilder {
            host: host.into(),
            port,
            max_connections: 100,
            timeout_secs: 30,
            tls_enabled: false,
            cert_path: None,
            log_level: "info".into(),
        }
    }
}

impl ServerConfigBuilder {
    fn max_connections(&mut self, n: usize) -> &mut Self {
        self.max_connections = n;
        self
    }

    fn timeout(&mut self, secs: u64) -> &mut Self {
        self.timeout_secs = secs;
        self
    }

    fn tls(&mut self, cert_path: impl Into<String>) -> &mut Self {
        self.tls_enabled = true;
        self.cert_path = Some(cert_path.into());
        self
    }

    fn log_level(&mut self, level: impl Into<String>) -> &mut Self {
        self.log_level = level.into();
        self
    }

    fn build(self) -> ServerConfig {
        ServerConfig {
            host: self.host,
            port: self.port,
            max_connections: self.max_connections,
            timeout_secs: self.timeout_secs,
            tls_enabled: self.tls_enabled,
            cert_path: self.cert_path,
            log_level: self.log_level,
        }
    }
}

Callers get a clear, self-documenting API:

let config = ServerConfig::builder("0.0.0.0", 8080)
    .max_connections(500)
    .tls("/etc/cert.pem")
    .build();

Required parameters (host and port) go in builder(). Optional parameters have setters with defaults. build() produces the final struct.

Validation in build()

The build() method is where you enforce invariants:

fn build(self) -> Result<ServerConfig, String> {
    if self.tls_enabled && self.cert_path.is_none() {
        return Err("TLS enabled but no cert path provided".into());
    }
    Ok(ServerConfig { /* ... */ })
}

Cross-field validation belongs here. The builder accumulates state; build() validates the whole thing at once.

Consuming vs borrowing setters

The example above uses &mut self setters that return &mut Self. An alternative is consuming setters that take self and return Self:

fn timeout(mut self, secs: u64) -> Self {
    self.timeout_secs = secs;
    self
}

Consuming setters let you write the chain in one expression: let config = Builder::new().timeout(30).build();. Borrowing setters require a binding: let mut b = Builder::new(); b.timeout(30); b.build(); (or the chain works if you use the return value). Both styles are common. Consuming setters are slightly more ergonomic for chaining.

Derive macros

For straightforward cases, crates like derive_builder and bon generate the builder automatically from your struct definition. They reduce boilerplate when the builder is a mechanical mapping of fields to setters. For public APIs where you want full control over the builder's ergonomics and documentation, hand-written builders are often worth the effort.

When to use it

When not to: structs with two or three fields, or purely internal types where a simple new() is clear enough. A builder for a struct with two fields is overhead without benefit.

See it in practice: Building a Chat Server #4: Commands and Plugins uses this pattern for server configuration.