The Rust Builder Design Pattern

Constructing a Builder in Rust with proper Error Handling and Shared Data with `Rc`
February 15, 2026



Introduction

I love Rust, and lately I've been focusing on using Rust a lot more in my professional and hobby development. As such, I plan on devoting a lot more time on Rust articles going forward. I thought I would start here with a short introduction to a well-known but often under-baked concept.

The Rust Builder Design Pattern has been discussed at length in blogs and tutorials, but I am yet to come across an example of using the builder pattern along with proper Error Handling, not to mention extending it by incorporating other unique Rust characteristics such as Reference Counting (Rc).

So let's do that.




The Builder Design Pattern

There is an entire book on Rust design patterns so I will not belabour the definition(s) here except to say that in general, the builder pattern is part of a larger sub-catagory of Creational Patterns that deal with creating objects.

The builder design involves the object being built (usually as a struct) and an object builder. Each builder method returns a builder by value, and an ultimate .build() method returns the object.


struct Object {
    // fields ...
}

struct ObjectBuilder {
    // fields ...
}

impl ObjectBuilder {
    fn new() -> Self {...}
    fn foo() -> Self {...}
    fn bar() -> Self {...}
    fn build() -> Object {...}
}

fn main() {
    let my_object: Object = ObjectBuilder::new()
        .foo()
        .bar()
        .build();
}
        


The Code

First off let's grab some crates. I'm going to use anyhow and thiserror for the Error Handling portions and these are the only two crates you will need to $ cargo add to your project. Aside from those, you will also need to use Rc.


use anyhow::Result as AnyhowResult;     // For tidy error display in `main()`
use thiserror::Error as ThisError;      // For Error Handling
use std::rc::Rc;
        

The 'Config' Object & Error Handling

For this project imagine your program contains a Config that needs to be used by other portions of your program, such as a Cache and a Repo. The Config contains 3 fields: a host (String), a port (u32), and a timeout (u32).

Let's first define our Config, as well implement a Display trait for printing.

#[derive(Debug)]
struct Config {
    host: String,
    port: u32,
    timeout: u32,
}

impl std::fmt::Display for Config {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f, "Host: {}\nPort: {}\nTimeout: {} secs",
            self.host, self.port, self.timeout
        )
    }
}

Next, let's define the ConfigBuilder that will utilize the builder design pattern to construct the Config.

We design our ConfigBuilder so that the fields of Config are optional, using Rust's Option, and we instantiate them all as None.

We also make sure to handle prohibited hosts and ports within each of their repective build methods, and also set a timeout limit. We do this by custom error handling, which I will discuss below.

struct ConfigBuilder {
    host: Option<String>,
    port: Option<u32>,
    timeout: Option<u32>,
}

impl ConfigBuilder {
    fn new() -> Self {
        Self { host: None, port: None, timeout: None }
    }

    fn host(mut self, host: &str) -> ConfigResult<Self> {
        let prohibited: Vec<&str> = vec!["1.2.3.4", "5.6.7.8"];
        if prohibited.contains(&host) {
            return Err( HostError { h: host.to_string() } )
        }
        self.host = Some(host.to_string());
        Ok( self )
    }

    fn port(mut self, port: u32) -> ConfigResult<Self> {
        let prohibited: Vec<u32> = vec![0000, 9000];
        if prohibited.contains(&port) {
            return Err( PortError { p: port } )
        }
        self.port = Some(port);
        Ok( self )
    }

    fn timeout(mut self, seconds: u32) -> ConfigResult<Self> {
        if seconds > 120 {
            return Err( TimeoutError { s: seconds } )
        }
        self.timeout = Some(seconds);
        Ok( self )
    }

    fn build(self) -> Rc<Config> {
        let conf: Config = Config {
            host: self.host.unwrap_or_else(|| "localhost".to_string()),
            port: self.port.unwrap_or_else(|| 1111),
            timeout: self.timeout.unwrap_or_else(|| 0)
        };
        Rc::new( conf )
    }
}

I really like using the thiserror crate for custom error handling. I used to spend a lot of time writting boilerplate error code for custom errors (which is admittedly fun in Rust!), but I have since switched to using thiserror.

The pattern here is pretty self-explanatory. We define the ConfigError enum and derive the necessary implementations using #[derive(ThisError, Debug)].

We then define an error variant for each of our 3 build methods. Each variant has a field that corresponds to the variable that causes the particular error, which allows us to return a useful error message for each build method.

#[derive(ThisError, Debug)]
enum ConfigError {
    #[error("[HostError] Invalid host provided: '{}'", h)]
    HostError { h: String },

    #[error("[PortError] Invalid port provided: '{}'", p)]
    PortError { p: u32 },

    #[error("[TimeoutError] Proved timeout ({} secs) exceeds max 120 secs", s)]
    TimeoutError { s: u32 },
}

use crate::ConfigError::*;

// A Custom Result that uses `ConfigError`:
type ConfigResult<T> = Result<T, ConfigError>;

The 'Cache' & 'Repo' structs and their Implementation

Next, let's define the Cache and Repo structs, as well as the ProgramAPI trait that we will implement for each of them. The key concept here is that both Cache and Repo only need READ-ONLY access to the Config, which is why we are using Rust's Rc. The ProgramAPI trait is used here to mimick some program API that your code might use to pass Config to Cache and Repo, along with some trait methods that utilize the Config.

The 3 methods that I define for the ProgramAPI trait are supposed to be placeholders for the purposes of this article, but they retain the functionality neccessary to to drive the point home.

▪️ load() : Loads the Config so that it can be used by Cache and/or Repo

▪️ ref_count() : Prints the Reference Count associated with the object

▪️ remove_reference() : Analogous to using Drop, removes the Reference Count associated with the object

#[derive(Debug)]
struct Cache {
    config: Rc<Config>
}

#[derive(Debug)]
struct Repo {
    config: Rc<Config>
}


// The `ProgramAPI` trait:
trait ProgramAPI {
    fn load(rc_config: &Rc<Config>) -> Self;

    fn ref_count(&self);

    fn remove_reference(self);
}

// Implement for `Cache`:
impl ProgramAPI for Cache {
    fn load(rc_config: &Rc<Config>) -> Self {
        Self { config: rc_config.clone() }
    }

    fn ref_count(&self) {
        println!("[ Reference Count: {} ]", Rc::strong_count(&self.config));
    }

    fn remove_reference(self) {
        // [DO SOMETHING WITH OBJECT]
        println!("[ Reference REMOVED ]")
    }
}

// Implement for `Repo`:
impl ProgramAPI for Repo {
    fn load(rc_config: &Rc<Config>) -> Self {
        Self { config: rc_config.clone() }
    }

    fn ref_count(&self) {
        println!("[ Reference Count: {} ]", Rc::strong_count(&self.config));
    }

    fn remove_reference(self) {
        // [DO SOMETHING WITH OBJECT]
        println!("[ Reference REMOVED ]")
    }
}


Running Our Code

Everything in this section is run in main(), set up with our AnyhowResult for tidy error output:

fn main() -> AnyhowResult<()> {

    // CODE...

    Ok(())
}

Let's first check out how our builder logic turned out.

Let's build a new Config with host 127.0.0.1 and port 8080. We are going to omit the .timeout() method because we don't want to set a timeout right now, which should be handled automatically by our builder logic.

let config: Rc<Config> = ConfigBuilder::new()
    .host("127.0.0.1")?
    .port(8080)?
    .build();

println!("{}", config);
// Host: 127.0.0.1
// Port: 8080
// Timeout: 0 secs

Great! Our Config was built successfully and the timeout is 0 seconds like we wanted.


That's all well and good, but let's see what happens when we try to build a Config with some prohibited hosts / ports, and when we try to set a timeout that exceeds our 120 second maximum:

let config: Rc<Config> = ConfigBuilder::new()
    .host("1.2.3.4")?   // prohibited host
    .port(8080)?
    .build();
// Error: [HostError] Invalid host provided: '1.2.3.4'


let config: Rc<Config> = ConfigBuilder::new()
    .host("127.0.0.1")?
    .port(9000)?        // prohibited port
    .build();
// Error: [PortError] Invalid port provided: '9000'


let config: Rc<Config> = ConfigBuilder::new()
    .host("127.0.0.1")?
    .port(8080)?
    .timeout(125)?      // timeout exceeds the 120 second maxiumum
    .build();
// Error: [TimeoutError] Proved timeout (125 secs) exceeds max 120 secs

Perfect! In each case, the correct Error type was returned, along with a helpful error message.


Ok, finally let's see how to our Config will be used by Cache and Repo.

We instantiate 4 Cache objects by loading the same Config into each. We also instantiate 2 Repo objects, again by loading them with the same Config.

Periodically we check the Reference Count, and we see that at every step the Reference Count is exactly the number of associated references (plus 1 for the Config reference to itself).

When we use .remove_reference() to remove any of the associated references, the reference count predictably drops by 1.

let cache_1: Cache = Cache::load(&config);
let cache_2: Cache = Cache::load(&config);
let cache_3: Cache = Cache::load(&config);
let cache_4: Cache = Cache::load(&config);

cache_4.ref_count();
// [ Reference Count: 5 ]

let repo_1: Repo = Repo::load(&config);
let repo_2: Repo = Repo::load(&config);

repo_2.ref_count();
// [ Reference Count: 7 ]

repo_2.remove_reference();
// [ Reference REMOVED ]

repo_1.ref_count();
// [ Reference Count: 6 ]

Neat!



Summary

I'm not great at writting summaries. Rust is fun. The Builder Pattern is fun. Error Handling is fun.

As always, thank you very much for reading!