Implementing an Object-Oriented Design Pattern
Walk through implementing the state pattern in Rust using trait objects and an alternative type-driven approach, illustrated by a blog post workflow.
A central challenge when building applications is managing state that changes over time. A blog post might start as a draft, go through review, and finally become published — and the operations allowed on the post depend on which state it’s currently in. The state pattern is an object-oriented design technique that models this by making each state a separate object responsible for its own behavior and transitions.
We’re going to explore two Rust implementations of the state pattern. The first is a direct translation of the traditional object-oriented style using trait objects. The second is an alternative that encodes state information directly into Rust’s type system, catching invalid state transitions at compile time rather than at runtime. Seeing both approaches side by side teaches a lot about how Rust can honour OO patterns while still leveraging its unique strengths.
Defining a Blog Post Workflow
Before any code, we need a clear picture of the behaviour we’re aiming for. The library we’ll write should support a simple content publishing flow:
- A post starts as an empty draft.
- Text can be added to the draft at any time.
- Requesting a review moves the post into a pending review state.
- While pending review or still in draft, asking for the post’s content returns an empty string — nothing is visible until the post is approved.
- Approving the post makes it published; only then does
content()return the actual text. - Attempting to approve a draft post without first requesting a review has no effect.
Here’s what the user-facing API looks like when everything is in place:
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 only public type is Post. All state transitions happen internally — the caller never manipulates state objects directly. This encapsulation prevents misuse like publishing a post that hasn’t been reviewed.
If you stepped through the workflow as a user of the library, it would look like this:
Create a new draft post
Call Post::new() to get a blank post in the Draft state. The content field starts as an empty String.
Add text to the draft
Use add_text to push a &str onto the internal content. The state doesn’t affect this operation — it just accumulates text.
Request a review
request_review() transitions the state from Draft to PendingReview. The post still isn’t visible to the outside world.
Approve the post
approve() moves the state from PendingReview to Published. The content is now officially ready.
Read the published content
content() now returns the full string. If called before approval, it would still be empty.
This workflow is the specification. Every implementation decision from here on exists only to make these rules hold — and to make them hard to break accidentally.
The State Pattern with Trait Objects
The traditional object-oriented way to implement the state pattern uses a State trait and separate structs for each concrete state. Post holds a trait object behind a Box, and each state method consumes the old state object to produce a new one.
The heart of the design is this: the Post struct asks its current state object to handle an action, and the state object returns the next state. Post doesn’t decide what the next state is; it just replaces its own state with whatever comes back.
Struct and trait setup
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
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 {}
struct PendingReview {}
struct Published {}
state is Option<Box<dyn State>> — not just Box<dyn State>. That Option lets us temporarily move the state value out of the Post during a transition while still leaving a valid (but empty) None placeholder. It’s a pattern you’ll see whenever ownership needs to be juggled across a mutable reference.
The Option is not optional:
Without Option, you can’t move the Box<dyn State> out of self.state while self is behind &mut self. Rust forbids partial moves from a borrowed struct. Using Option::take gets ownership cleanly and leaves None behind, so the struct is never left in an inconsistent state.
State transitions consume themselves
Each method on the State trait takes self: Box<Self>. This syntax means the method can only be called on a Box that holds the state, and it takes ownership of the whole box — effectively destroying the current state object. The method then returns a new Box<dyn State> that becomes the next state.
For Draft, requesting a review produces a PendingReview:
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 // no effect: stays Draft
}
}
For PendingReview, approval creates a Published:
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self // already pending, no change
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
Published is the terminal state; calling any transition on it just returns itself.
Wiring Post to delegate
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 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());
}
}
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
}
The content method on Post delegates to the state’s content method, passing a reference to the Post itself. The default implementation in the State trait returns an empty string, so Draft and PendingReview don’t need to override it. Published overrides content to return the actual stored text:
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
}
}
Dynamic dispatch at work:
The Box<dyn State> uses dynamic dispatch. When Post calls state.request_review(), the call goes through a vtable — Rust figures out at runtime which concrete impl to invoke. This is the standard object-oriented polymorphism cost: a small runtime overhead in exchange for the flexibility of treating different state types uniformly through a single trait.
What this approach gets right — and what it doesn’t
The trait-object approach hides state transitions completely. Users of Post can’t create an invalid state sequence by accident, because they have no direct access to the state objects. Adding a new state (say Scheduled) would only mean adding a new struct and implementing the State trait — no changes to Post itself.
However, some problems remain. Calling approve on a Draft post compiles and runs without a crash, but does nothing silently — the state stays Draft. The type system has no way to prevent you from calling approve at the wrong time. The state validity is enforced at runtime, through careful method implementations, not through the compiler.
Another subtle weakness: because content() defaults to returning an empty string, forgetting to override it for Published would be a silent bug. Nothing at the trait level signals “this method must be overridden for the behaviour to be correct.”
Forgotten content override leads to lost text:
If you implement Published but accidentally omit the content method override, the default from the State trait kicks in — returning an empty string forever. The code compiles, the post “publishes,” but readers see nothing. The trait system can’t express “you must provide this method” without a different design.
Encoding States and Behavior as Types
Rust offers a different way to enforce state rules: make each state its own type, not just a value of a shared trait. Instead of a single Post struct that mutates its internal state, we can define separate structs like DraftPost, PendingReviewPost, and PublishedPost. Each struct exposes only the methods that are valid in that state, and state transitions consume the current struct and return the next one.
This encodes the state machine into the type system. If you try to call approve on a DraftPost, the compiler will tell you there is no such method — it’s not a runtime no-op, it’s a compile-time error.
Designing the type-driven workflow
pub struct Post {
content: String,
}
pub struct DraftPost {
content: String,
}
impl Post {
pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
}
impl DraftPost {
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn request_review(self) -> PendingReviewPost {
PendingReviewPost {
content: self.content,
}
}
}
pub struct PendingReviewPost {
content: String,
}
impl PendingReviewPost {
pub fn approve(self) -> PublishedPost {
PublishedPost {
content: self.content,
}
}
}
pub struct PublishedPost {
content: String,
}
impl PublishedPost {
pub fn content(&self) -> &str {
&self.content
}
}
Post::new() doesn’t return a Post — it returns a DraftPost. That DraftPost has an add_text method that mutates its content, and a request_review method that takes ownership of self (consuming the draft) and returns a PendingReviewPost. There’s no way to get a PendingReviewPost except by calling request_review on a draft.
Impossible states are unrepresentable:
With this design, you literally cannot call approve before request_review because the approve method only exists on PendingReviewPost. The compiler guarantees the sequence: draft → review → published. There is no runtime check, no silent failure, no need for the Option dance to move ownership.
Usage from the client side
The calling code changes slightly, because the type of the post now shifts with each step:
use blog::Post;
fn main() {
let mut post = Post::new(); // DraftPost
post.add_text("I ate a salad for lunch today");
let post = post.request_review(); // consumes DraftPost, returns PendingReviewPost
let post = post.approve(); // consumes PendingReviewPost, returns PublishedPost
assert_eq!("I ate a salad for lunch today", post.content());
}
No mutable borrowing across state transitions — each step takes ownership and returns a new, differently-typed value. content() is only available on PublishedPost, so trying to read content from a draft would be a compile error, not a runtime empty string.
Comparing the two approaches
| Trait-object approach | Type-encoding approach | |
|---|---|---|
| Compile-time state checking | No; invalid methods are no-ops at runtime | Yes; invalid methods don’t exist on the type |
| Number of types | One Post, one State trait, many state structs | One type per state, plus a starting Post |
| Dynamic dispatch | Yes (Box<dyn State>) | No; everything is monomorphised |
| Ownership handling | Requires Option + take | Natural ownership passing (consume & return) |
| Extensibility | New states require implementing the trait, but Post unchanged | New states require a new struct; callsites must be updated if they pattern‑match on type |
Neither approach is universally better. The trait-object style feels familiar to developers coming from OO languages and keeps the outward Post type stable even as new states are added. The type-encoding style leans into Rust’s strengths, turning state bugs into type errors and eliminating the need for the Option dance and dynamic dispatch.
The blog post example is deliberately small. In a larger system where state transitions are complex and mistakes are expensive, the type-driven approach often pays off because it moves the burden of correctness from tests and careful runtime checks onto the compiler.
Real-world relevance:
The pattern of encoding state as types appears in Rust libraries that deal with connection state (e.g., a TLS handshake), parsing stages, or build pipelines. It’s sometimes called the typestate pattern and is one of the patterns that makes Rust APIs feel unusually robust — the compiler won’t let you call send on a socket that isn’t connected, because the “connected” state is a different type.
What This Tells Us About Rust and OOP
Rust’s relationship with object-oriented design is sometimes described as “OOP without inheritance.” This chapter’s example makes that concrete. The state pattern — a classic OO design — maps cleanly onto Rust traits and trait objects when you want that style. But Rust also offers tools that let you compile away whole categories of bugs by making illegal states unrepresentable, something that purely class-based OO languages often struggle to express without extra patterns or run-time checks.
When you encounter a design problem that feels like a state machine, the question is no longer “how do I translate the state pattern into Rust?” but rather “which Rust representation — traits or types — captures the guarantees I need?” The blog post workflow, simple as it is, gives you both answers and the criteria to choose.