Defining a Blog Post Workflow
Understand how to model a blog post state machine using the state pattern in Rust, starting with the definition of the Post struct, the State trait, and the initial draft state.
The Blog Post Workflow
A blog post passes through several stages before it’s visible to readers: it begins as an empty draft, goes through a review, and finally gets published. We want to enforce that only published posts return their content—drafts and posts awaiting review must expose no text. Any other operations, like adding text, should work regardless of state. The state pattern captures these rules by letting each state object govern its own transitions and behavior.
The API we’ll build is used like this:
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());
}
This snippet shows the complete workflow we’ll implement. For now we’ll focus on the structural foundation: the Post struct, the State trait, the initial Draft state, and the methods that work before any state transitions exist.
Step 1: Create a new draft
Calling Post::new() returns a post whose state is Draft. It holds no content, and any attempt to read content returns an empty string. The user can then call add_text to build up the body.
Step 2: Add text
The add_text method appends a string slice to the post’s internal content buffer. This operation is independent of the state—drafts, reviews, and published posts all accept text additions. The method does not interact with the state system at all.
Step 3: Request a review
request_review will later transition the post from Draft to PendingReview. In the PendingReview state, content remains hidden. This step is irreversible; once a review is requested, the post cannot return to a draft.
Step 4: Approve the post
approve moves the post from PendingReview to Published. Only after this step does content() return the actual text. Calling approve on a post that isn’t in PendingReview has no effect—the state pattern silently rejects invalid transitions.
The Stepper shows the intended linear progression, but the state pattern also handles misordered calls transparently. For example, approving a draft does nothing and the post stays a draft. Now we’ll build the crate that supports this workflow, starting with the Post struct and the state infrastructure.
Defining Post and the State Trait
The Post struct needs two pieces of data: the current state and the accumulated content. Because the state will eventually be one of several types that share a common interface, we store it as a trait object. To allow the state to be consumed and replaced during transitions, we wrap the trait object in an Option. The content is simply a String.
We also declare a private State trait that all states must implement, and a Draft struct (the starting state) that implements it.
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
trait State {}
struct Draft {}
impl State for Draft {}
The Post struct is public; everything else is private to the crate. Users interact only with Post, never with State or its implementors. This encapsulation means we can later add new states or change transition rules without breaking external code. The State trait currently has no methods—we’ll add them when we implement transitions. For now, having the trait allows Post to hold a trait object that can later become any state type.
Why Option<Box<dyn State>>?:
The Option wrapper lets us temporarily extract the current state value during a transition using Option::take, leaving a None in its place. Rust forbids moving out of a struct field without replacing it, so this pattern is necessary when we need to consume the old state and set a new one.
Creating a New Draft Post
Post::new must initialize the state field with a Draft inside a Box, wrapped in Some, and start with an empty String for content.
impl Post {
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
}
Because state is private, no external code can create a Post in any state other than Draft. This guarantees that every post begins life as a draft. The new function is the sole entry point for constructing a Post.
Everything compiles:
At this point your library crate should compile without errors. Run cargo build in the blog crate to confirm. There’s no runnable behavior yet, but the types and trait are correctly wired.
Adding Text to a Post
Users build a post’s content by calling add_text with a string slice. This method simply appends to the internal content field. It does not consult the state—that’s intentional: we want text to accumulate regardless of whether the post is a draft, under review, or published.
impl Post {
// --snip--
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
The &mut self borrow allows mutation of the Post. The method pushes the provided text onto the existing content string. Later, when the state transitions to Published, the same content field will be returned. Because this behavior is state-agnostic, it lives directly on Post rather than being delegated to the state objects.
Reading Content (Placeholder)
The content method must return an empty string slice while the post is not published. Since we haven’t implemented state transitions yet, the simplest approach is to always return "". This placeholder satisfies the first assertion in our workflow demo: after adding text, content() still returns nothing.
impl Post {
// --snip--
pub fn content(&self) -> &str {
""
}
}
This is a temporary placeholder:
The content method currently ignores the state and the stored text. Once we implement the Published state, we’ll update content to delegate to the state object and only return the text when the state allows it. Using this placeholder now lets us run the first part of the workflow and confirm that a draft post indeed hides its content.
Right now, calling content() after adding text silently returns an empty string. That’s exactly the behavior we want for a draft—no accidental leaks.
Putting It Together
At this stage, the blog crate contains:
- A
Poststruct with a private state and content. - A
Statetrait with no methods. - A
Draftstruct implementingState. - A constructor
newthat starts inDraft. - An
add_textmethod that builds the content. - A
contentmethod that always returns""(temporary).
If you write the main.rs example from the workflow and comment out the request_review() and approve() lines, the first assertion should pass:
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content()); // passes
Content remains hidden until approval:
Calling content() on a post that hasn’t been approved yet—whether it’s a Draft or PendingReview—returns an empty string, even though the text is stored internally. This design prevents unapproved material from being exposed. It’s a logical guard, not a cryptographic lock; the text still exists in the struct’s content field. The method simply refuses to hand it out.
Summary
The blog post workflow is a classic state machine: Draft → PendingReview → Published. We’ve captured the initial static structure—the Post that owns a state trait object, the State trait, and the Draft implementor. The public API so far (new, add_text, content) works, albeit with a placeholder content method. This foundation uses traits and encapsulation in a way that mirrors traditional object-oriented design, while leveraging Rust’s ownership model (Box<dyn State> and Option) to enable safe state transitions later.
The state pattern’s real advantage becomes apparent once we start moving between states without altering the Post struct itself. When business requirements change—for instance, if we add a “scheduled” state or a “needs revision” state—we’ll only need to add new state types and implement the trait methods; Post and its callers remain untouched.