Skip to main content
Rust · lesson 28 of 30
Rust advanced Lesson 28 of 30

Also part of: Low-Level Design

Design Patterns in Rust

Implement idiomatic Rust design patterns — builder, newtype, typestate, RAII guards, strategy closures and observers — as complete runnable programs.

By SkillByExample · Updated

Builder Pattern

The builder pattern constructs complex objects step by step. In Rust it is especially useful because you cannot have optional fields set to undefined — everything must be initialized.

#[derive(Debug)]
#[allow(dead_code)] // fields are only inspected through Debug in this demo
struct Request {
    url: String,
    method: String,
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
    timeout_ms: u64,
    follow_redirects: bool,
}

#[derive(Default)]
struct RequestBuilder {
    url: Option<String>,
    method: String,
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
    timeout_ms: u64,
    follow_redirects: bool,
}

impl RequestBuilder {
    fn new() -> Self {
        Self {
            method: "GET".to_string(),
            timeout_ms: 30_000,
            follow_redirects: true,
            ..Default::default()
        }
    }

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

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

    fn header(mut self, key: &str, value: &str) -> Self {
        self.headers.push((key.to_string(), value.to_string()));
        self
    }

    fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
        self.body = Some(body.into());
        self
    }

    fn timeout(mut self, ms: u64) -> Self {
        self.timeout_ms = ms;
        self
    }

    fn build(self) -> Result<Request, String> {
        let url = self.url.ok_or("URL is required")?;
        Ok(Request {
            url,
            method: self.method,
            headers: self.headers,
            body: self.body,
            timeout_ms: self.timeout_ms,
            follow_redirects: self.follow_redirects,
        })
    }
}

fn main() {
    let request = RequestBuilder::new()
        .url("https://api.example.com/users")
        .method("POST")
        .header("Content-Type", "application/json")
        .header("Authorization", "Bearer token123")
        .body(r#"{"name":"Alice"}"#)
        .timeout(5_000)
        .build()
        .unwrap();

    println!("{:#?}", request);
}
$ rustc design_patterns_builder_pattern.rs && ./design_patterns_builder_pattern
Request {
    url: "https://api.example.com/users",
    method: "POST",
    headers: [
        (
            "Content-Type",
            "application/json",
        ),
        (
            "Authorization",
            "Bearer token123",
        ),
    ],
    body: Some(
        [
            123,
            34,
            110,
            97,
            109,
            101,
            34,
            58,
            34,
            65,
            108,
            105,
            99,
            101,
            34,
            125,
        ],
    ),
    timeout_ms: 5000,
    follow_redirects: true,
}

Newtype Pattern

Wrap a primitive in a tuple struct to create a semantically distinct type and prevent mixing values with the same underlying type:

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Meters(f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Seconds(f64);

#[derive(Debug, Clone, Copy)]
struct MetersPerSecond(f64);

impl Meters {
    fn value(self) -> f64 { self.0 }
}

impl Seconds {
    fn value(self) -> f64 { self.0 }
}

impl std::ops::Div<Seconds> for Meters {
    type Output = MetersPerSecond;
    fn div(self, t: Seconds) -> MetersPerSecond {
        MetersPerSecond(self.0 / t.0)
    }
}

// Implement Display
impl std::fmt::Display for Meters {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{}m", self.0)
    }
}

fn main() {
    let distance = Meters(100.0);
    let time = Seconds(9.58);
    let speed = distance / time;

    println!("distance: {}", distance);
    println!("speed: {:.2} m/s", speed.0);
    println!("raw values: {} and {}", distance.value(), time.value());

    // This would be a compile error — you cannot accidentally pass
    // Seconds where Meters is expected:
    // fn needs_meters(m: Meters) {}
    // needs_meters(time); // ERROR: expected Meters, found Seconds
}
$ rustc design_patterns_newtype_pattern.rs && ./design_patterns_newtype_pattern
distance: 100m
speed: 10.44 m/s
raw values: 100 and 9.58

Newtype for validated data:

#[derive(Debug, Clone)]
struct EmailAddress(String);

impl EmailAddress {
    fn new(s: &str) -> Result<Self, String> {
        if s.contains('@') && s.contains('.') {
            Ok(EmailAddress(s.to_lowercase()))
        } else {
            Err(format!("'{}' is not a valid email address", s))
        }
    }

    fn as_str(&self) -> &str { &self.0 }
}

fn send_email(to: &EmailAddress, subject: &str) {
    println!("sending '{}' to {}", subject, to.as_str());
}

fn main() {
    let email = EmailAddress::new("Alice@Example.COM").unwrap();
    send_email(&email, "Hello!");
    // send_email(&"not-an-email".to_string(), "Hi"); // won't compile
}
$ rustc design_patterns_newtype_pattern_2.rs && ./design_patterns_newtype_pattern_2
sending 'Hello!' to alice@example.com

Typestate Pattern

Encode state transitions in the type system. Invalid state transitions become compile errors:

use std::marker::PhantomData;

// State types — zero-sized, used only as type markers
struct Locked;
struct Unlocked;

struct Safe<State> {
    contents: String,
    _state: PhantomData<State>,
}

impl Safe<Locked> {
    fn new(contents: &str) -> Self {
        Safe { contents: contents.to_string(), _state: PhantomData }
    }

    fn unlock(self, password: &str) -> Result<Safe<Unlocked>, Safe<Locked>> {
        if password == "secret" {
            Ok(Safe { contents: self.contents, _state: PhantomData })
        } else {
            Err(self)
        }
    }
}

impl Safe<Unlocked> {
    fn get_contents(&self) -> &str { &self.contents }

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

    fn lock(self) -> Safe<Locked> {
        Safe { contents: self.contents, _state: PhantomData }
    }
}

fn main() {
    let safe = Safe::<Locked>::new("diamonds");

    // Cannot access contents when locked:
    // safe.get_contents(); // ERROR: no method get_contents on Safe<Locked>

    let Ok(safe) = safe.unlock("secret") else { panic!("wrong password") };
    println!("contents: {}", safe.get_contents());

    let _locked = safe.put_contents("rubies").lock();
    // safe.get_contents(); // ERROR: locked again
    println!("safe is locked");
}
$ rustc design_patterns_typestate_pattern.rs && ./design_patterns_typestate_pattern
contents: diamonds
safe is locked

RAII — Resource Management

Rust’s Drop trait implements RAII naturally. Resources are always released when the owner goes out of scope:

use std::sync::Mutex;

struct ConnectionPool {
    connections: Mutex<Vec<String>>,
}

impl ConnectionPool {
    fn new(size: usize) -> Self {
        let connections = (0..size).map(|i| format!("conn_{}", i)).collect();
        Self { connections: Mutex::new(connections) }
    }

    fn acquire(&self) -> Option<PooledConnection<'_>> {
        let mut pool = self.connections.lock().unwrap();
        pool.pop().map(|conn| PooledConnection {
            conn,
            pool: &self.connections,
        })
    }
}

struct PooledConnection<'a> {
    conn: String,
    pool: &'a Mutex<Vec<String>>,
}

impl<'a> PooledConnection<'a> {
    fn execute(&self, query: &str) -> String {
        format!("[{}] executed: {}", self.conn, query)
    }
}

impl<'a> Drop for PooledConnection<'a> {
    fn drop(&mut self) {
        // Connection automatically returned to pool
        println!("returning {} to pool", self.conn);
        self.pool.lock().unwrap().push(self.conn.clone());
    }
}

fn main() {
    let pool = ConnectionPool::new(3);

    {
        let conn = pool.acquire().expect("no connections available");
        println!("{}", conn.execute("SELECT 1"));
    } // conn dropped here — automatically returned to pool

    println!("pool size: {}", pool.connections.lock().unwrap().len());
}
$ rustc design_patterns_raii_resource_management.rs && ./design_patterns_raii_resource_management
[conn_2] executed: SELECT 1
returning conn_2 to pool
pool size: 3

Strategy Pattern with Closures

struct Sorter<T, F: Fn(&T, &T) -> std::cmp::Ordering> {
    comparator: F,
    _phantom: std::marker::PhantomData<T>,
}

impl<T, F: Fn(&T, &T) -> std::cmp::Ordering> Sorter<T, F> {
    fn new(comparator: F) -> Self {
        Self { comparator, _phantom: std::marker::PhantomData }
    }

    fn sort(&self, data: &mut Vec<T>) {
        data.sort_by(|a, b| (self.comparator)(a, b));
    }
}

fn main() {
    let mut numbers = vec![3, 1, 4, 1, 5, 9, 2, 6];

    let asc_sorter = Sorter::new(|a: &i32, b| a.cmp(b));
    asc_sorter.sort(&mut numbers);
    println!("{:?}", numbers);

    let desc_sorter = Sorter::new(|a: &i32, b| b.cmp(a));
    desc_sorter.sort(&mut numbers);
    println!("{:?}", numbers);

    // Sort strings by length
    let mut words = vec!["banana", "apple", "cherry", "fig"];
    let len_sorter = Sorter::new(|a: &&str, b: &&str| a.len().cmp(&b.len()));
    len_sorter.sort(&mut words);
    println!("{:?}", words);
}
$ rustc design_patterns_strategy_pattern_with_closures.rs && ./design_patterns_strategy_pattern_with_closures
[1, 1, 2, 3, 4, 5, 6, 9]
[9, 6, 5, 4, 3, 2, 1, 1]
["fig", "apple", "banana", "cherry"]

Observer Pattern with Trait Objects

trait Observer {
    fn on_event(&self, event: &str);
}

struct EventSource {
    observers: Vec<Box<dyn Observer>>,
}

impl EventSource {
    fn new() -> Self { Self { observers: Vec::new() } }

    fn subscribe(&mut self, obs: Box<dyn Observer>) {
        self.observers.push(obs);
    }

    fn emit(&self, event: &str) {
        for obs in &self.observers {
            obs.on_event(event);
        }
    }
}

struct Logger { prefix: String }
struct Counter { count: std::cell::Cell<u32> }

impl Observer for Logger {
    fn on_event(&self, event: &str) {
        println!("[{}] {}", self.prefix, event);
    }
}

impl Observer for Counter {
    fn on_event(&self, _event: &str) {
        self.count.set(self.count.get() + 1);
    }
}

fn main() {
    let mut source = EventSource::new();
    source.subscribe(Box::new(Logger { prefix: "LOG".to_string() }));
    source.subscribe(Box::new(Counter { count: std::cell::Cell::new(0) }));

    source.emit("user_login");
    source.emit("page_view");
    source.emit("user_logout");
}
$ rustc design_patterns_observer_pattern_with_trait_objects.rs && ./design_patterns_observer_pattern_with_trait_objects
[LOG] user_login
[LOG] page_view
[LOG] user_logout

Practice

  1. In the typestate example, call safe.get_contents() on the freshly created Safe<Locked>. What kind of error is it — runtime or compile time?

    Solution

    Compile time:

    error[E0599]: no method named `get_contents` found for struct `Safe<Locked>` in the current scope

    get_contents only exists in impl Safe<Unlocked>, so reading a locked safe isn’t a check that could fail — the method simply isn’t there.

  2. In the newtype example, call a function fn needs_meters(m: Meters) with Seconds(9.58).

    Solution
    error[E0308]: mismatched types
    ...
      |     needs_meters(Seconds(9.58));
      |     ------------ ^^^^^^^^^^^^^ expected `Meters`, found `Seconds`

    Both wrap an f64, but they are distinct types, and the wrapper costs nothing at runtime.

  3. Acquire all three pooled connections at once, then try a fourth. What does acquire return, and what happens to the pool when the three guards go out of scope?

    Solution

    The fourth call returns None — the pool is empty while all three guards are alive. When their scope ends, each Drop pushes its connection back, and the pool size returns to 3.

Frequently Asked Questions

Does Rust have traditional OOP design patterns?
Many OOP patterns (strategy, observer, factory) translate directly using traits and closures. Some patterns like inheritance don't exist, but Rust's ownership model enables unique patterns like typestate and RAII.
What is the typestate pattern?
The typestate pattern encodes state transitions in the type system. Invalid transitions become compile errors rather than runtime errors.
What is the newtype pattern?
The newtype pattern wraps an existing type in a single-field tuple struct to create a distinct type. It prevents mixing up logically different values of the same underlying type.