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
- Structs with more than three or four fields, especially with optional ones
- Public APIs where callers shouldn't need to know field order
- Construction that requires cross-field validation
- Configurable objects (HTTP clients, loggers, server configs)
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.
Telex