Skip to content

07 · Design Patterns in Rust

Classic OOP design patterns exist to work around limitations Rust doesn't have — inheritance-based polymorphism, null references, mutable shared state as the default. Rust's own idioms cover the same problems with traits, Option, ownership, and the type system doing compile-time checking that GoF patterns did at runtime (or not at all). This module walks four patterns that show up constantly in real Rust code: builder, strategy, newtype, and typestate.

Builder: construct in steps, validate at the end

#[derive(Debug)]
struct Server {
    host: String,
    port: u16,
    tls: bool,
}

struct ServerBuilder {
    host: String,
    port: u16,
    tls: bool,
}

impl ServerBuilder {
    fn new() -> Self {
        ServerBuilder { host: "127.0.0.1".into(), port: 8080, tls: false }
    }
    fn host(mut self, host: &str) -> Self {
        self.host = host.to_string();
        self
    }
    fn port(mut self, port: u16) -> Self {
        self.port = port;
        self
    }
    fn tls(mut self, tls: bool) -> Self {
        self.tls = tls;
        self
    }
    fn build(self) -> Server {
        Server { host: self.host, port: self.port, tls: self.tls }
    }
}

fn main() {
    let server = ServerBuilder::new().host("0.0.0.0").port(443).tls(true).build();
    println!("{:?}", server);
}
Server { host: "0.0.0.0", port: 443, tls: true }

Each builder method takes self by value and returns Self, so calls chain without a mutable local variable. This is the pattern Rust reaches for instead of optional constructor arguments — Server has no Default or new() with five parameters where three are usually the same value.

Strategy: swap behavior through a trait object

trait Discount {
    fn apply(&self, cents: u32) -> u32;
}

struct NoDiscount;
impl Discount for NoDiscount {
    fn apply(&self, cents: u32) -> u32 {
        cents
    }
}

struct PercentOff(u32);
impl Discount for PercentOff {
    fn apply(&self, cents: u32) -> u32 {
        cents - (cents * self.0 / 100)
    }
}

fn checkout(cents: u32, strategy: &dyn Discount) -> u32 {
    strategy.apply(cents)
}

fn main() {
    let strategies: Vec<Box<dyn Discount>> = vec![Box::new(NoDiscount), Box::new(PercentOff(20))];
    for s in &strategies {
        println!("{}", checkout(2500, s.as_ref()));
    }
}
2500
2000

&dyn Discount is a trait object — a fat pointer (data pointer + vtable) that lets checkout accept any type implementing Discount without knowing which one at compile time. This is Rust's answer to strategy: runtime polymorphism through a trait, not a class hierarchy. Prefer impl Discount generics when you know the concrete type at each call site — dyn earns its keep specifically when you need a heterogeneous collection like the Vec above.

Newtype: a zero-cost wrapper with its own semantics

struct Cents(u32);
impl std::fmt::Display for Cents {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "${}.{:02}", self.0 / 100, self.0 % 100)
    }
}

fn main() {
    println!("{}", Cents(2599));
}
$25.99

Cents(u32) is a tuple struct with one field — same memory layout as a bare u32, zero runtime overhead, but it's a distinct type. A function taking Cents can't accidentally be called with a raw u32 meaning "dollars" or "quantity"; the newtype makes the unit part of the type signature instead of a comment. It's also the standard way to implement a foreign trait (like Display) for a foreign type (like u32) — Rust's orphan rule forbids impl Display for u32 directly, but a local newtype sidesteps it.

Typestate: illegal states unrepresentable at compile time

struct Draft;
struct Published;

struct Post<State> {
    text: String,
    _state: std::marker::PhantomData<State>,
}

impl Post<Draft> {
    fn new(text: &str) -> Self {
        Post { text: text.to_string(), _state: std::marker::PhantomData }
    }
    fn publish(self) -> Post<Published> {
        Post { text: self.text, _state: std::marker::PhantomData }
    }
}

impl Post<Published> {
    fn text(&self) -> &str {
        &self.text
    }
}

fn main() {
    let draft = Post::<Draft>::new("hello world");
    let published = draft.publish();
    println!("{}", published.text());
}
hello world

text() only exists on Post<Published> — there is no way to call it on a Post<Draft>; that's a compile error, not a runtime check. PhantomData<State> carries the type parameter without storing any actual data of that type (the struct's runtime layout is identical regardless of State). This is the pattern for state machines where an invalid transition should be impossible to write, not just impossible to reach at runtime.

Rust-specific traps

Builder methods that take &mut self instead of self. Returning &mut Self chains fine but forces the builder to live in a let mut variable and forces build() to take &self, which then can't move fields out without cloning. Taking self by value (as above) is more idiomatic — each step consumes the builder and hands back a new one.

dyn Trait needs Sized-free methods. A trait with a method returning Self or taking Self by value can't be made into a trait object at all — the compiler rejects Box<dyn Discount> with "the trait cannot be made into an object" if Discount ever grows a method like fn combine(self, other: Self) -> Self. This is discovered late, often after a trait has many implementors, forcing an API redesign.

Typestate transitions that borrow instead of consume. fn publish(&self) -> Post<Published> (borrowing instead of taking self) lets the original Draft value keep existing and being published again — the type-level guarantee ("a post is published exactly once, from a draft") silently evaporates. fn publish(self) is required for the trick to actually enforce anything; the whole pattern rests on ownership being consumed.

Newtype boilerplate for arithmetic. Cents above only implements Display; the moment you want Cents + Cents or Cents * u32, you're manually implementing std::ops::Add/Mul per operation — there's no #[derive] for arithmetic traits, unlike Debug or Clone.

Cheat sheet

Pattern Rust mechanism
Builder Chained self -> Self methods, final build(self) -> T
Strategy &dyn Trait or generic impl Trait parameter
Newtype Tuple struct wrapping one field, own trait impls
Typestate Generic struct parameterized by marker types + PhantomData
Singleton (rare in Rust) once_cell/std::sync::OnceLock, not a class-level static
Observer Channel (mpsc) or callback Vec<Box<dyn Fn(...)>>

How It Actually Works

The typestate pattern (Post<Draft>, Post<Published>) enforces its rule entirely at compile time and vanishes completely by runtime: PhantomData<State> is a zero-sized type, so Post<Draft> and Post<Published> are laid out identically in memory — same fields, same size, same bytes — and monomorphization (Module 3) generates entirely separate impl blocks per State, so fn publish(self) -> Post<Published> only even exists as a method on Post<Draft>. There is no runtime state field to check and no possible "wrong state" branch to forget to handle — the compiler simply never generates code that calls .publish() on an already-published post, because that method doesn't exist for that type. This is the strongest form of "make invalid states unrepresentable": not a runtime check that could be skipped, but an absence of the method from the type's very API.

The dyn Trait-object-safety trap is a direct consequence of how trait objects are represented (Module 3's vtable explanation): a vtable stores function pointers whose signatures must be uniform and knowable without knowing the concrete Self type, so a method taking or returning Self by value can't have a vtable entry — the vtable can't know at compile time how large a same-type return value would be for whichever concrete type ends up behind the trait object. Builder methods consuming self by value rather than &mut self matters for the same ownership reason as the newtype pattern's move semantics from Module 1: passing self moves the whole builder value into the method, letting it destructure and reconstruct fields without cloning, whereas &mut self only ever gives you a borrow you can mutate in place, not one you can move fields out of.

Exercise

Extend the typestate example with a third state, Archived, reachable only from Post<Published> via an archive(self) -> Post<Archived> method. Give Post<Archived> its own method, restore(self) -> Post<Draft>, and verify in main that a Post<Draft> cannot call archive() directly — paste the exact compiler error you get when you try.