Skip to Content
Course content

217: The Builder Pattern in Rust

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this happen in almost every mid-sized Rust project I've touched. You start with a simple struct, maybe a HttpRequest or a DatabaseConfig, and you provide a new() method. It works great when you only have two or three fields. But then the requirements creep in. Suddenly, you need a timeout, a custom header map, a retry policy, and a proxy setting. Before you know it, your constructor looks like a telephone number.

The Constructor From Hell

When we're in a rush, the "naive" approach is to just keep adding arguments to the constructor. It looks something like this:

struct HttpRequest {
    url: String,
    method: String,
    timeout: u32,
    headers: Vec<String>,
    body: Option<String>,
}

impl HttpRequest {
    pub fn new(url: String, method: String, timeout: u32, headers: Vec<String>, body: Option<String>) -> Self {
        Self { url, method, timeout, headers, body }
    }
}

Now, imagine calling this. You're forced to provide every single value, even if you just want the defaults. You end up writing HttpRequest::new("https://api.com".into(), "GET".into(), 30, vec![], None). It's noisy, and it's fragile. If I decide tomorrow that we need a user_agent field, I have to go back and update every single call site in the entire codebase. That's a recipe for a miserable afternoon.

You might be tempted to just make every field an Option and pass a struct of options. But then you've just pushed the problem further down the line; now the logic that actually uses the HttpRequest has to constantly unwrap options or provide defaults, which clutters the business logic with configuration boilerplate.

Separating Intent From Construction

This is where the Builder pattern earns its keep. Instead of one giant function, we create a separate Builder struct. This builder holds the "draft" state of our object. We provide methods to set the optional fields, and only at the very end do we call a build() method to produce the final, immutable HttpRequest.

Here is how I usually structure this in Rust to keep it ergonomic:

#[derive(Debug)]
pub struct HttpRequest {
    url: String,
    method: String,
    timeout: u32,
    headers: Vec<String>,
}

pub struct HttpRequestBuilder {
    url: String,
    method: String,
    timeout: u32,
    headers: Vec<String>,
}

impl HttpRequestBuilder {
    pub fn new(url: String) -> Self {
        Self {
            url,
            method: "GET".to_string(), // Default value
            timeout: 30,               // Default value
            headers: Vec::new(),       // Default value
        }
    }

    pub fn method(mut self, method: &str) -> Self {
        self.method = method.to_string();
        self
    }

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

    pub fn header(mut self, header: String) -> Self {
        self.headers.push(header);
        self
    }

    pub fn build(self) -> HttpRequest {
        HttpRequest {
            url: self.url,
            method: self.method,
            timeout: self.timeout,
            headers: self.headers,
        }
    }
}

Notice how the methods take mut self and return Self. This allows us to chain calls together. Now, the call site becomes a readable sentence: HttpRequestBuilder::new("https://api.com".into()).method("POST").timeout(60).build(). If we add a new field to the builder, the existing code doesn't break because the new() method handles the default for that new field.

The Cost of the Extra Boilerplate

I'll be honest with you: the Builder pattern is verbose. You're essentially defining your data twice—once in the HttpRequest and once in the HttpRequestBuilder. In a small project, this might feel like overkill. If you only have two optional fields, just use a new() method and a few setter methods on the main struct.

But once you hit that tipping point where your constructor has more than four or five arguments, or where the combination of arguments must be validated (e.g., "if you provide a body, you must also provide a content-length header"), the builder is a lifesaver. It allows you to move the validation logic into the build() method, ensuring that it's impossible to instantiate an "invalid" HttpRequest. You aren't just organizing code; you're using Rust's type system to enforce a correct state.




📋 Practical Task

Implementing a DatabaseConnection Builder

You are building a database library. A DatabaseConnection requires a connection_string (required), but can optionally have a pool_size (default: 10), a timeout (default: 5000ms), and a read_only flag (default: false).

Your task is to implement the Builder pattern for this connection. Create the following:

  1. A DatabaseConnection struct with the four fields mentioned.
  2. A DatabaseConnectionBuilder struct.
  3. A new(connection_string: String) method for the builder that initializes the defaults.
  4. Fluent methods for pool_size, timeout, and read_only that return Self.
  5. A build(self) method that returns the final DatabaseConnection.

Test your implementation by creating a connection that uses a custom pool_size and read_only flag, but keeps the default timeout.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.