When constructors get out of hand
You're building a configuration struct for a database connection. It needs a host, a port, a username, a password, a pool size, a timeout, an SSL mode, and a retry count. Three fields are mandatory. The rest have sensible defaults. You write the struct. Now you need to create an instance. You reach for the struct literal. Suddenly you're staring at a line of code with eight arguments, half of them Some(...) and the other half None. You change the order of fields in the struct, and every instantiation site breaks. You add a new optional field, and you have to update every call site to add field: None. The code is fragile and hard to read.
The builder pattern solves this by separating construction from the final object. Instead of one giant constructor, you get a helper object that starts with defaults. You call methods to tweak what you need. Finally, you call a method to produce the result. Think of it like filling out a multi-step form. The form starts with pre-filled defaults. You edit the fields that matter. When you hit "Submit", the system validates everything and creates the final record. In Rust, the "form" is a separate struct. The "Submit" button is the build() method.
The builder pattern in plain words
A builder is a struct dedicated to constructing another struct. It holds the same fields as the target, but it exposes methods to set them one by one. Each setter returns the builder itself, which allows you to chain calls. The final build method consumes the builder and returns the target struct.
This pattern shines when you have many optional parameters, when you need to validate configuration before the object exists, or when you want a fluent API that reads like documentation. The builder pattern is a zero-cost abstraction. The compiler optimizes the method calls away. You get the readability of a fluent API without paying for runtime overhead. The builder struct lives on the stack. The moves are cheap. The final struct is constructed directly.
A minimal builder
Here is the basic structure. The builder holds the fields, starts with defaults, and exposes setters that return Self.
struct Server {
port: u16,
host: String,
timeout: Option<u32>,
}
struct ServerBuilder {
port: u16,
host: String,
timeout: Option<u32>,
}
impl ServerBuilder {
fn new() -> Self {
// Start with sensible defaults so the caller doesn't have to specify everything.
ServerBuilder {
port: 8080,
host: "127.0.0.1".to_string(),
timeout: None,
}
}
fn port(mut self, port: u16) -> Self {
self.port = port;
// Return self to allow method chaining.
self
}
fn host(mut self, host: String) -> Self {
self.host = host;
self
}
fn timeout(mut self, timeout: u32) -> Self {
self.timeout = Some(timeout);
self
}
fn build(self) -> Server {
// Consume the builder to create the final struct.
Server {
port: self.port,
host: self.host,
timeout: self.timeout,
}
}
}
fn main() {
let server = ServerBuilder::new()
.port(7878)
.host("0.0.0.0".to_string())
.build();
}
Return self from every setter. Chaining is the contract. If a setter returns (), the chain breaks and the pattern fails.
How chaining works
The magic happens in the setter methods. Each method takes mut self as an argument. This moves the builder into the method, lets you modify a field, and returns the builder back. Because the method returns Self, you can chain calls. builder.port(80).host("...") works because port returns a builder, which then has host available.
When you call ServerBuilder::new(), you get a builder. You call .port(7878). The builder moves into the port method. The method updates the port field and returns the builder. The returned builder is immediately used to call .host(...). This repeats until you call .build(). The build method takes self by value, not by reference. This consumes the builder. Rust moves the fields from the builder into the final struct. You can't accidentally build two servers from the same builder configuration.
If you write fn port(self, port: u16) -> Self without mut, the compiler rejects you with E0596 (cannot borrow as mutable). You need mut self to change the fields. Trust the compiler on mut self. If it complains, you're trying to mutate an immutable value.
Realistic example with validation
In production code, builders often enforce invariants. You don't want to create a Server with an empty host or a port of zero. The build method is the perfect place to check everything. If validation fails, build returns a Result.
use std::fmt;
#[derive(Debug)]
struct Server {
port: u16,
host: String,
}
#[derive(Debug)]
struct ServerBuilder {
port: Option<u16>,
host: Option<String>,
}
impl ServerBuilder {
fn new() -> Self {
// Use Option fields to track whether the caller provided a value.
ServerBuilder { port: None, host: None }
}
fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
fn host(mut self, host: String) -> Self {
self.host = Some(host);
self
}
fn build(self) -> Result<Server, BuildError> {
// Validate required fields exist.
let port = self.port.ok_or(BuildError::MissingPort)?;
let host = self.host.ok_or(BuildError::MissingHost)?;
// Validate values.
if port == 0 {
return Err(BuildError::InvalidPort);
}
if host.is_empty() {
return Err(BuildError::InvalidHost);
}
Ok(Server { port, host })
}
}
#[derive(Debug)]
enum BuildError {
MissingPort,
MissingHost,
InvalidPort,
InvalidHost,
}
impl fmt::Display for BuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BuildError::MissingPort => write!(f, "port is required"),
BuildError::MissingHost => write!(f, "host is required"),
BuildError::InvalidPort => write!(f, "port cannot be zero"),
BuildError::InvalidHost => write!(f, "host cannot be empty"),
}
}
}
fn main() {
let result = ServerBuilder::new()
.port(8080)
.build();
match result {
Ok(server) => println!("Server: {:?}", server),
Err(e) => println!("Error: {}", e),
}
}
Validate in build. Don't let invalid objects exist. If build returns T, you can't enforce invariants. The caller could construct a broken state. Returning Result forces the caller to handle errors and guarantees that a successful build produces a valid object.
Convention asides
The community follows a few small conventions that make builders feel idiomatic. Name the builder struct StructNameBuilder. The method to produce the result is almost always build. If you implement From<Builder> for T, users can write Server::from(builder) or builder.into(). This integrates the builder with Rust's standard conversion traits and allows the builder to work in contexts that expect the target type.
Another convention is to derive Default on the builder when the defaults are simple. This lets users write ServerBuilder::default() instead of ServerBuilder::new(). It also allows partial initialization using struct update syntax: ServerBuilder { port: Some(80), ..Default::default() }. This is useful when you want to override a few fields but keep the rest as defaults.
Pitfalls and compiler errors
Builders are simple, but a few mistakes trip up beginners.
If you return &self from a setter, you can chain, but you can't call build if build takes self. You'll get a lifetime error or be forced to change build to take &self. Returning &self allows the builder to be used multiple times, which might be what you want, but it breaks the consumption pattern. Most builders consume themselves in build. Stick to returning self.
If you make build take &self, the builder isn't consumed. You can call build multiple times. This is fine if the builder is cheap to clone or if you want to reuse it. But if the builder holds resources that should be moved, returning a reference might cause issues. Decide early whether the builder is single-use or reusable.
If you forget to mark a field as mut in the builder struct, you can't set it in the setters. The compiler will reject the assignment. This is rare because you usually mark the whole builder as mutable in the method signature, but if you use &mut self instead of mut self, you need the fields to be mutable. Using mut self is the standard approach.
If you try to use a builder after calling build, the compiler rejects you with E0382 (use of moved value). The builder was consumed. This is a feature. It prevents accidental reuse of a builder that might have moved resources. If you need to build multiple times, clone the builder before building.
When to use a builder
Pick the tool that matches the complexity. Don't build a builder for a two-field struct.
Use a builder when the struct has many optional fields or requires validation during construction. Use a builder when you want a fluent API where method names document the configuration options. Use a builder when the construction logic involves multiple steps that should be grouped together.
Use a struct literal when all fields are required and the number of fields is small. The builder adds unnecessary boilerplate for simple cases. Use a constructor function when the construction logic is complex but doesn't benefit from step-by-step configuration. Use #[derive(Default)] with public fields when the struct is simple and you don't need to enforce invariants at build time.
Don't fight the struct literal. If you have three fields, just write them. Builders are for complexity, not for style points.