The State Pattern with Trait Objects

A complete walkthrough of implementing the state design pattern in Rust using trait objects, Option take mechanics, and the blog post workflow example

What Problem the State Pattern Solves

An object sometimes needs to behave differently depending on what condition it is in right now. A blog post that is still a draft should not show its content to readers. A payment that is pending should not send a confirmation email. A file upload that is in progress should not mark itself as complete.

Without a pattern, the code that handles these situations tends to accumulate if statements and match arms that check a field — if self.status == "draft", match self.state { Draft => ... } — and every new state forces you to hunt down every one of those checks and add another branch. Miss one, and you have a bug that the compiler cannot catch.

The state pattern pulls each state's behavior into its own type. The main object holds a reference to a state object and delegates to it. When the state changes, the main object swaps in a different state object, and all the delegated methods automatically use the new behavior. No if chains. No forgotten branches.

The Gang of Four Definition:

The state pattern is one of the 23 design patterns from the book Design Patterns - Elements of Reusable Object-Oriented Software. It was originally written for languages with classes and inheritance, but the core idea — separate state objects, delegation, and encapsulated transitions — maps cleanly onto Rust's traits and structs.

In a language like Java or C++, you would use a base class or interface for the state, then subclass it for each concrete state. Rust does not have classes or inheritance, but it has traits and trait objects. The translation is direct: the trait plays the role of the interface, and each state struct implements that trait.

The Blog Post Workflow

The example this section builds is a blog post that moves through three states — Draft, PendingReview, and Published — with strict rules about which transitions are allowed.

A post starts as an empty draft. Text can be added at any time, but calling content() on a draft or a post under review returns an empty string. Only after the post is approved does content() return the actual text. Requesting a review on a draft moves it to PendingReview. Approving a PendingReview post moves it to Published. Calling approve on a draft does nothing — the post stays a draft.

The API the user interacts with is a single type, Post. The states are invisible from the outside. The user calls post.request_review() and post.approve(), and the Post handles which state object is active and whether a transition actually happens.

use blog::Post;
fn main() {
    let mut post = Post::new();
    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());
    post.request_review();
    assert_eq!("", post.content());
    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

The three assertions tell the story. After creation, content is empty. After requesting review, content is still empty. Only after approval does the text surface. The caller never sees a Draft or PendingReview type — only Post.

The Pieces of the Pattern

Four structural components make up this implementation. Understanding how they relate before seeing the code makes the code easier to follow.

First, the Post struct holds two things: the actual text content in a String, and a state field that stores the current state object. The state field has the type Option<Box<dyn State>>. The Box is needed because trait objects have a size that is not known at compile time — the concrete type behind dyn State could be Draft, PendingReview, or Published, each potentially a different size. The Option wrapper exists because Rust will not let you temporarily move a value out of a struct field while leaving the field unpopulated; Option gives you a None variant to use as a placeholder during transitions.

Second, the State trait declares the methods that every state must implement: request_review, approve, and content. Some states will implement these by transitioning to a new state; others will return the current state unchanged, effectively ignoring the call.

Third, the concrete state structs — Draft, PendingReview, Published — are empty structs that exist only to carry behavior. They have no fields of their own because, in this design, all the data lives on Post. Each implements State differently.

Fourth, the methods on Post that the user calls — request_review, approve, content — are thin wrappers. They reach into the state field, call the corresponding method on the trait object, and store whatever new state comes back.

Defining the Post and the State Trait

Start with the struct and the trait, plus the first concrete state. A new Post always begins in the Draft state.

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}
impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}
trait State {}
struct Draft {}
impl State for Draft {}

Post::new wraps a Draft in a Box, then wraps that Box in Some. The content field starts as an empty String. The State trait has no methods yet — those come once the transition logic is added. The Draft struct has no fields; it exists purely to be a type that implements State.

The state field is private. There is no way for external code to construct a Post that starts in PendingReview or Published. Every post begins as a draft, and the only path out of that state goes through the methods on Post.

Encapsulation Through Privacy:

Because state is private and Post::new always sets it to Draft, the invariant "a post always starts as a draft" is enforced by the type system. No runtime check is needed — the constructor simply does not offer any other option.

Adding Text Without Touching the State

The add_text method is the simplest part. It does not interact with the state pattern at all. It appends a string slice to the content field, and that behavior is the same regardless of whether the post is a draft, under review, or published.

impl Post {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

The method takes &mut self because it modifies content. It calls push_str on the String, which appends the given text. No state check, no delegation — just a straightforward mutation of a field.

Keeping add_text outside the state pattern is deliberate. Not every behavior needs to vary by state. Text accumulation is always allowed, so it belongs directly on Post. Only behaviors that should change based on state — like whether content is visible or whether a transition is valid — get routed through the state object.

The Placeholder Content Method

Before any state transitions exist, the content method has a simple job: always return an empty string slice. This satisfies the first assertion in the workflow — a draft post shows no content.

impl Post {
    pub fn content(&self) -> &str {
        ""
    }
}

This is a placeholder. It ignores the content field entirely and returns a static empty string literal. Once the Published state exists, content will be rewritten to delegate to the state object, and only Published will return the actual text.

Requesting a Review — the First State Transition

The transition from Draft to PendingReview introduces the two mechanics that make the state pattern work in Rust: Option::take() for moving the state value out of the Post, and self: Box<Self> for consuming the old state object.

The self: Box<Self> Receiver

A normal method takes &self or &mut self. A method that transitions between states needs to consume the old state entirely — the old state object should cease to exist, replaced by a new one. In Rust, that means taking ownership.

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

The parameter self: Box<Self> means this method can only be called when you already have a Box<Self>. It takes ownership of the box and everything inside it. The return type is Box<dyn State> — a new boxed trait object representing the next state.

self: Box<Self> is Not &self:

Beginners often try to write fn request_review(&self) or fn request_review(&mut self) on the State trait and then attempt to return a new state. That does not work because you cannot move out of a reference — you do not own the value. The self: Box<Self> syntax gives the method full ownership so it can destroy the old state and hand back a new one.

Option::take() and the Ownership Dance

The Post struct holds state: Option<Box<dyn State>>. To call request_review on the state, you need to move the box out of the Option. But Rust will not let you move out of a struct field while leaving it empty. Option::take() solves this: it pulls the Some value out and leaves a None in its place.

impl Post {
    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review());
        }
    }
}

Step by step: self.state.take() extracts the Box<dyn State> (if present) and replaces self.state with None. The if let Some(s) binds that box to s. Then s.request_review() consumes the box and returns a new Box<dyn State>. The result is wrapped in Some and placed back into self.state.

Direct Assignment Will Not Compile:

Writing self.state = self.state.request_review(); fails with a compiler error about moving out of a field behind a mutable reference. Rust sees that you are trying to read self.state (to call .request_review()) while simultaneously writing to self.state (to assign the result). The take() method breaks this cycle by inserting a None placeholder, satisfying the borrow checker.

Concrete Implementations for Draft and PendingReview

Each state struct now needs to implement request_review. Draft transitions to PendingReview. PendingReview stays in PendingReview — requesting review on a post already under review has no effect.

struct Draft {}
impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}
struct PendingReview {}
impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

For Draft, the method creates a new PendingReview, boxes it, and returns it. The old Draft is consumed and dropped. For PendingReview, the method returns self unchanged — the box and its contents are handed back as-is. No new allocation, no state change.

This is where the pattern earns its keep. Adding a fourth state — say, Scheduled for posts with a future publish date — would require adding one new struct, implementing State for it, and deciding what request_review does on it. No existing state code changes. The Post struct does not change. Code that calls post.request_review() does not change.

Approving a Post

The approve method follows the same structure: a thin wrapper on Post that calls through to the state object, and state-specific implementations that decide whether a transition occurs.

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}
impl Post {
    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve());
        }
    }
}

On Draft, calling approve does nothing — the post returns to Draft. On PendingReview, it transitions to Published. On Published, it stays Published.

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
struct Published {}
impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}
impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

The Draft implementation of approve returns self — the box is passed through, and the post remains a draft. This is how the pattern prevents publishing a post that has not been reviewed. The caller writes post.approve() and gets no error, but the state machine silently ignores the request because Draft's approve does not produce a Published state.

Delegating Content to the State

Once Published exists, the placeholder content method on Post is replaced with one that delegates to the state. The state needs access to the post's text to return it — but only Published will actually use it.

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
    fn content<'a>(&self, _post: &'a Post) -> &'a str {
        ""
    }
}

The content method on State takes &self (not Box<Self> — this is a read-only query, not a transition) and a reference to the Post. It has a default implementation that returns an empty string slice. Draft and PendingReview inherit this default. Only Published overrides it.

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

The lifetime annotation 'a ties the returned string slice to the lifetime of the Post reference. The caller of Post::content gets back a &str that borrows from the Post's content field. This is safe because the reference cannot outlive the Post it came from.

The Post::content wrapper passes a reference to itself into the state's content method:

impl Post {
    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
}

self.state.as_ref() converts &Option<Box<dyn State>> into Option<&Box<dyn State>>, letting us borrow the state without moving it. The unwrap() is safe here because state is always Some — the only time it becomes None is during the brief window inside request_review or approve when take() has run but the new state has not yet been stored. By the time any other method can be called, state is Some again.

Why unwrap() is Safe Here:

The Option is None only inside the if let Some(s) = self.state.take() blocks in request_review and approve. Those methods take &mut self, so no other method can be called on the same Post while state is None. By the time content is called (which takes &self), the transition is complete and state is always Some. The unwrap() will never panic in practice.

Putting It All Together

Here is the complete library. Every piece described above, in one place.

src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}
impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review());
        }
    }
    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve());
        }
    }
}
trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
    fn content<'a>(&self, _post: &'a Post) -> &'a str {
        ""
    }
}
struct Draft {}
impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
struct PendingReview {}
impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}
struct Published {}
impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

Run the example from the beginning of this section against this library, and all three assertions pass. The post starts as a draft, ignores the approve call until review has been requested, and only reveals its content after reaching the Published state.

What This Approach Costs You

The trait-object state pattern works, but it carries trade-offs that become apparent as the number of states and behaviors grows.

Redundant method implementations. Every state must implement every method on the State trait, even methods that are meaningless for that state. Draft has an approve method that does nothing but return self. Published has a request_review method that does nothing. These are not bugs — the methods exist to satisfy the trait contract — but they are noise. In a system with ten states and eight transition methods, you write dozens of self-returning stubs.

Runtime state verification. Calling approve on a draft compiles and runs without error. It just does not do what the caller might expect. The compiler cannot tell you that you called the wrong method at the wrong time because the Post type is the same regardless of internal state. You only discover the mistake at runtime — or, worse, you do not discover it at all because the method silently ignores the call.

Heap allocation for every state change. Each Box::new(...) allocates on the heap. For a blog post that changes state a handful of times over its lifetime, this is negligible. For a high-throughput system where state transitions happen millions of times per second, the allocation overhead adds up.

The Option and unwrap() ceremony. The state field is always logically present, but the Option wrapper and the take()-and-replace dance exist purely to satisfy the borrow checker. The unwrap() in content is safe, as explained, but it still represents a runtime check that the compiler cannot optimize away.

When the State Count Grows:

If your state machine has more than four or five states, the trait-object approach becomes difficult to maintain. Each new state requires implementing every method on the State trait, and most of those implementations will be no-op self returns.

Moving Forward

The implementation in this section demonstrates how a classic object-oriented design pattern translates into Rust using traits and trait objects. The self: Box<Self> receiver, Option::take(), and default trait method bodies are the Rust-specific mechanisms that make the pattern work without inheritance.

The result is a library that correctly enforces the blog post workflow rules — drafts cannot be published, content stays hidden until approval — but the enforcement happens at runtime. A caller who writes post.approve() on a draft gets a silent no-op, not a compiler error.

There is a different encoding of the same state machine, one that represents states as distinct types rather than as variants behind a trait object. In that approach, calling approve on a draft is not silently ignored — it is a compile-time error, because the Draft type simply does not have an approve method. The trade-off is that the Post type itself changes with each state transition, which complicates storing posts in collections or passing them across function boundaries. Both approaches are useful; which one fits depends on whether you need runtime flexibility or compile-time guarantees.