Actix-web Integration
Integrate xstate-statemachine with Actix-web (40M+ downloads), Rust’s battle-tested, high-performance web framework.
🎯 Architecture Overview
flowchart LR
C[HTTP Client] -->|POST /orders/:id/events| R[Actix Route Handler]
R -->|web::Data StatechartManager| M[Statechart Actor Pool]
M -->|Dispatch Event| I[xstate-core SyncInterpreter / Tokio Actor]
I -->|Receipt & Leaf State| R
R -->|JSON Response 200 OK| C
I -.->|SSE Stream| S[Stately Studio Inspector]
You can run statecharts in Actix-web in two modes:
- Per-Request Stateless Execution: Load state from database (
xstate-persistence), process the HTTP event, persist the new snapshot, and respond. - Long-Lived Resident Actors: Keep statechart actors in-memory wrapped in
web::Data, allowing instant transitions and live WebSocket/SSE streaming.
📦 Dependencies
In your Cargo.toml:
[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
xstate-statemachine = { version = "0.1", features = ["tokio", "persistence", "inspect"] }
🚀 Complete Example: Order Statechart Service
1. Define the Statechart
#![allow(unused)]
fn main() {
use std::sync::Arc;
use actix_web::{web, App, HttpResponse, HttpServer, Responder, post, get};
use serde::{Deserialize, Serialize};
use xstate_statemachine::prelude::*;
use xstate_statemachine::tokio::{spawn, Options, Interpreter};
const ORDER_MACHINE: &str = r#"{
"id": "order",
"initial": "draft",
"context": {"total": 0, "items": []},
"states": {
"draft": {
"on": {
"SUBMIT": "processing",
"CANCEL": "cancelled"
}
},
"processing": {
"on": {
"PAYMENT_CONFIRMED": "paid",
"PAYMENT_FAILED": "payment_failed"
}
},
"paid": {"type": "final"},
"payment_failed": {
"on": {"RETRY": "processing", "CANCEL": "cancelled"}
},
"cancelled": {"type": "final"}
}
}"#;
}
2. Statechart Application State (web::Data)
#![allow(unused)]
fn main() {
pub struct AppState {
pub machine: Arc<Machine>,
pub logic: Arc<MachineLogic>,
pub actors: dashmap::DashMap<String, Interpreter>,
}
impl AppState {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
let machine = Arc::new(Machine::from_json(ORDER_MACHINE)?);
let logic = Arc::new(MachineLogic::dynamic());
Ok(Self {
machine,
logic,
actors: dashmap::DashMap::new(),
})
}
pub fn get_or_create(&self, order_id: &str) -> Result<Interpreter, Box<dyn std::error::Error>> {
if let Some(actor) = self.actors.get(order_id) {
return Ok(actor.clone());
}
let actor = spawn(
(*self.machine).clone(),
(*self.logic).clone(),
Options::default(),
)?;
self.actors.insert(order_id.to_string(), actor.clone());
Ok(actor)
}
}
}
3. Route Handlers
#![allow(unused)]
fn main() {
#[derive(Deserialize)]
pub struct SendEventRequest {
pub event: String,
pub payload: Option<serde_json::Value>,
}
#[derive(Serialize)]
pub struct StateResponse {
pub order_id: String,
pub status: String,
pub current_states: Vec<String>,
}
#[post("/orders/{id}/events")]
async fn handle_event(
path: web::Path<String>,
req: web::Json<SendEventRequest>,
data: web::Data<AppState>,
) -> impl Responder {
let order_id = path.into_inner();
let actor = match data.get_or_create(&order_id) {
Ok(a) => a,
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
};
let mut event = Event::new(&req.event);
if let Some(payload) = &req.payload {
event = event.with_payload(payload.clone());
}
match actor.send_and_wait(event).await {
Ok(receipt) => {
let view = actor.view();
HttpResponse::Ok().json(StateResponse {
order_id,
status: view.status.as_str().to_string(),
current_states: view.state_ids,
})
}
Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
"error": err.to_string(),
})),
}
}
#[get("/orders/{id}/state")]
async fn get_state(
path: web::Path<String>,
data: web::Data<AppState>,
) -> impl Responder {
let order_id = path.into_inner();
match data.get_or_create(&order_id) {
Ok(actor) => {
let view = actor.view();
HttpResponse::Ok().json(StateResponse {
order_id,
status: view.status.as_str().to_string(),
current_states: view.state_ids,
})
}
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
}
}
}
4. Application Entry Point
#[actix_web::main]
async fn main() -> std::io::Result<()> {
tracing_subscriber::fmt::init();
let app_state = web::Data::new(AppState::new().expect("Failed to initialize statechart"));
println!("⚡ Actix-web Statechart Server running on http://127.0.0.1:8080");
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
.service(handle_event)
.service(get_state)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
🧪 Testing with actix_web::test
#![allow(unused)]
fn main() {
#[actix_web::test]
async fn test_order_transition_flow() {
let state = web::Data::new(AppState::new().unwrap());
let app = actix_web::test::init_service(
App::new().app_data(state.clone()).service(handle_event)
).await;
let req = actix_web::test::TestRequest::post()
.uri("/orders/order-101/events")
.set_json(&SendEventRequest {
event: "SUBMIT".into(),
payload: None,
})
.to_request();
let resp: StateResponse = actix_web::test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.current_states, vec!["order.processing"]);
}
}