Builder Pattern

Fluent APIs and compile-time validation

intermediate
buildercreationalfluent-api
🎮 Interactive Playground

What is the Builder Pattern?

The Builder pattern separates the construction of a complex object from its representation. In Rust, it's particularly powerful because we can use the type system to enforce that required fields are set before building.

The Problem

Builders solve several issues:

  • Many constructor parameters: Avoiding functions with 10+ arguments
  • Optional fields: Providing sensible defaults while allowing customization
  • Validation: Ensuring object is in valid state before construction
  • Fluent API: Making object creation readable

Example Code

use std::time::Duration;

/// The target struct with many fields
#[derive(Debug)]
pub struct HttpClient {
    base_url: String,
    timeout: Duration,
    max_retries: u32,
    user_agent: String,
    headers: Vec<(String, String)>,
    follow_redirects: bool,
}

/// Simple builder (all fields optional with defaults)
#[derive(Default)]
pub struct HttpClientBuilder {
    base_url: Option<String>,
    timeout: Option<Duration>,
    max_retries: Option<u32>,
    user_agent: Option<String>,
    headers: Vec<(String, String)>,
    follow_redirects: Option<bool>,
}

impl HttpClientBuilder {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = Some(url.into());
        self
    }

    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }

    pub fn max_retries(mut self, retries: u32) -> Self {
        self.max_retries = Some(retries);
        self
    }

    pub fn user_agent(mut self, agent: impl Into<String>) -> Self {
        self.user_agent = Some(agent.into());
        self
    }

    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((key.into(), value.into()));
        self
    }

    pub fn follow_redirects(mut self, follow: bool) -> Self {
        self.follow_redirects = Some(follow);
        self
    }

    pub fn build(self) -> Result<HttpClient, &'static str> {
        let base_url = self.base_url.ok_or("base_url is required")?;

        Ok(HttpClient {
            base_url,
            timeout: self.timeout.unwrap_or(Duration::from_secs(30)),
            max_retries: self.max_retries.unwrap_or(3),
            user_agent: self.user_agent.unwrap_or_else(|| "RustClient/1.0".to_string()),
            headers: self.headers,
            follow_redirects: self.follow_redirects.unwrap_or(true),
        })
    }
}

/// Type-state builder: compile-time validation
#[derive(Debug)]
pub struct Server {
    host: String,
    port: u16,
    tls: bool,
    workers: usize,
}

// Marker types for type-state
pub struct NoHost;
pub struct HasHost;
pub struct NoPort;
pub struct HasPort;

/// Type-state builder that requires host and port
pub struct ServerBuilder<H, P> {
    host: Option<String>,
    port: Option<u16>,
    tls: bool,
    workers: usize,
    _host_state: std::marker::PhantomData<H>,
    _port_state: std::marker::PhantomData<P>,
}

impl ServerBuilder<NoHost, NoPort> {
    pub fn new() -> Self {
        ServerBuilder {
            host: None,
            port: None,
            tls: false,
            workers: num_cpus::get().unwrap_or(4),
            _host_state: std::marker::PhantomData,
            _port_state: std::marker::PhantomData,
        }
    }
}

impl<P> ServerBuilder<NoHost, P> {
    pub fn host(self, host: impl Into<String>) -> ServerBuilder<HasHost, P> {
        ServerBuilder {
            host: Some(host.into()),
            port: self.port,
            tls: self.tls,
            workers: self.workers,
            _host_state: std::marker::PhantomData,
            _port_state: std::marker::PhantomData,
        }
    }
}

impl<H> ServerBuilder<H, NoPort> {
    pub fn port(self, port: u16) -> ServerBuilder<H, HasPort> {
        ServerBuilder {
            host: self.host,
            port: Some(port),
            tls: self.tls,
            workers: self.workers,
            _host_state: std::marker::PhantomData,
            _port_state: std::marker::PhantomData,
        }
    }
}

impl<H, P> ServerBuilder<H, P> {
    pub fn tls(mut self, enabled: bool) -> Self {
        self.tls = enabled;
        self
    }

    pub fn workers(mut self, count: usize) -> Self {
        self.workers = count;
        self
    }
}

// Only implement build() when both host and port are set
impl ServerBuilder<HasHost, HasPort> {
    pub fn build(self) -> Server {
        Server {
            host: self.host.unwrap(),
            port: self.port.unwrap(),
            tls: self.tls,
            workers: self.workers,
        }
    }
}

/// Builder with derive macro (using typed-builder crate pattern)
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
    pub host: String,
    pub port: u16,
    pub database: String,
    pub user: String,
    pub password: Option<String>,
    pub max_connections: u32,
    pub connect_timeout: Duration,
}

/// Manual implementation of what typed-builder generates
pub struct DatabaseConfigBuilder {
    host: String,
    port: Option<u16>,
    database: String,
    user: Option<String>,
    password: Option<String>,
    max_connections: Option<u32>,
    connect_timeout: Option<Duration>,
}

impl DatabaseConfigBuilder {
    pub fn new(host: impl Into<String>, database: impl Into<String>) -> Self {
        DatabaseConfigBuilder {
            host: host.into(),
            port: None,
            database: database.into(),
            user: None,
            password: None,
            max_connections: None,
            connect_timeout: None,
        }
    }

    pub fn port(mut self, port: u16) -> Self {
        self.port = Some(port);
        self
    }

    pub fn user(mut self, user: impl Into<String>) -> Self {
        self.user = Some(user.into());
        self
    }

    pub fn password(mut self, password: impl Into<String>) -> Self {
        self.password = Some(password.into());
        self
    }

    pub fn max_connections(mut self, max: u32) -> Self {
        self.max_connections = Some(max);
        self
    }

    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
        self.connect_timeout = Some(timeout);
        self
    }

    pub fn build(self) -> DatabaseConfig {
        DatabaseConfig {
            host: self.host,
            port: self.port.unwrap_or(5432),
            database: self.database,
            user: self.user.unwrap_or_else(|| "postgres".to_string()),
            password: self.password,
            max_connections: self.max_connections.unwrap_or(10),
            connect_timeout: self.connect_timeout.unwrap_or(Duration::from_secs(5)),
        }
    }
}

// Helper for simulating num_cpus
mod num_cpus {
    pub fn get() -> Option<usize> {
        Some(8)
    }
}

fn main() {
    // Simple builder
    let client = HttpClientBuilder::new()
        .base_url("https://api.example.com")
        .timeout(Duration::from_secs(60))
        .header("Authorization", "Bearer token123")
        .header("Accept", "application/json")
        .max_retries(5)
        .build()
        .unwrap();

    println!("HTTP Client: {:?}", client);

    // Type-state builder (compile-time safety)
    let server = ServerBuilder::new()
        .host("localhost")
        .port(8080)
        .tls(true)
        .workers(4)
        .build();

    println!("Server: {:?}", server);

    // This won't compile - missing host!
    // let invalid = ServerBuilder::new()
    //     .port(8080)
    //     .build();

    // Database config builder
    let db_config = DatabaseConfigBuilder::new("localhost", "myapp")
        .port(5432)
        .user("admin")
        .password("secret")
        .max_connections(20)
        .build();

    println!("Database: {:?}", db_config);
}

Why This Works

  1. Method chaining: Each method returns self, enabling fluent API
  2. impl Into: Accept &str, String, etc. without explicit conversion
  3. Type-state pattern: PhantomData markers track compile-time state
  4. Default values: unwrap_or provides sensible defaults

Builder Patterns Comparison

| Pattern | Validation | Compile-time Safety | Complexity |

|---------|------------|---------------------|------------|

| Simple Builder | Runtime (Result) | No | Low |

| Type-state Builder | Compile-time | Yes | High |

| Derive macro (typed-builder) | Compile-time | Yes | Low |

When to Use

  • Many optional parameters: More than 3-4 optional fields
  • Complex validation: Fields depend on each other
  • Immutable objects: Object shouldn't change after creation
  • DSLs: Creating readable configuration code

⚠️ Anti-patterns

// DON'T: Builder for simple types
struct PointBuilder { x: i32, y: i32 }
impl PointBuilder {
    fn build(self) -> Point { Point { x: self.x, y: self.y } }
}
// Just use: Point { x: 1, y: 2 }

// DON'T: Mutable builder that can be reused incorrectly
let mut builder = Builder::new();
builder.name("first");
let first = builder.build();
// builder still has state from first!
let second = builder.build(); // Accidentally reuses state

// DO: Consume builder on build
fn build(self) -> Product { /* consumes self */ }

Real-World Usage

  • reqwest: ClientBuilder for HTTP client configuration
  • tokio: Runtime::Builder for async runtime setup
  • serde_json: JsonBuilder for constructing JSON
  • clap: Command-line argument parser configuration

Exercises

  1. Add validation to HttpClientBuilder (e.g., URL format, positive timeout)
  2. Create a type-state builder that requires fields in a specific order
  3. Implement a builder that can create multiple objects (clone before build)
  4. Add a with_defaults() method that pre-populates common configurations

🎮 Try it Yourself

🎮

Builder Pattern - Playground

Run this code in the official Rust Playground