Recipe: Event-Driven Architecture & Transactional Outbox
When state machine transitions must emit events to external message brokers (NATS, RabbitMQ, Apache Kafka, AWS SQS), doing network I/O directly inside transition actions violates transactional boundaries (dual-write hazard).
This recipe demonstrates using xstate-eda to implement the Transactional Outbox Pattern: state transitions write an outgoing event atomically to the database, and an outbox relay reliably delivers it with at-least-once guarantees.
Architecture Flow
sequenceDiagram
participant App as Application Core
participant DB as SQLite / PostgreSQL (Outbox)
participant Relay as Outbox Relay Worker
participant Broker as NATS / RabbitMQ / SQS
participant Consumer as Inbound Dispatcher
App->>DB: 1. Atomically write State Snapshot + Outbox Record
Note over DB: Local Transaction (No 2PC needed)
Relay->>DB: 2. Claim pending outbox records (Lease Lock)
Relay->>Broker: 3. Publish CloudEvent 1.0 message
Broker-->>Relay: Ack
Relay->>DB: 4. Mark record completed / delete
Broker->>Consumer: 5. Deliver event
Consumer->>Consumer: Idempotency deduplication check
Rust Implementation
use std::sync::Arc;
use xstate_eda::envelope::Envelope;
use xstate_eda::outbox::{MemoryOutbox, OutboxStore};
use xstate_statemachine::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Initialize outbox store
let outbox = Arc::new(MemoryOutbox::new());
// 2. Define the payment statechart
let machine = Machine::from_json(r#"{
"id": "payment",
"initial": "authorizing",
"states": {
"authorizing": {
"on": {"APPROVED": "completed"}
},
"completed": {"type": "final"}
}
}"#)?;
// 3. Process transition
let mut interp = SyncInterpreter::new(machine, MachineLogic::dynamic());
interp.start()?;
interp.send("APPROVED")?;
// 4. Construct a compliant CloudEvents 1.0 envelope
let event = Envelope::new(
"payment.completed",
"orders/8492",
serde_json::json!({
"order_id": "8492",
"amount_cents": 4900,
"currency": "USD"
}),
);
// 5. Stage to outbox atomically
outbox.stage(&event).await?;
println!("Event staged to transactional outbox with ID: {}", event.id);
// 6. Outbox relay claims and publishes batch
let pending_batch = outbox.claim_batch(10, std::time::Duration::from_secs(30)).await?;
assert_eq!(pending_batch.len(), 1);
for record in &pending_batch {
println!("Relay dispatching to broker: topic={}, event_id={}", record.envelope.subject, record.envelope.id);
// ... publish to NATS or RabbitMQ ...
}
// Acknowledge completed delivery
let ids: Vec<_> = pending_batch.iter().map(|r| r.envelope.id.as_str()).collect();
outbox.mark_published(&ids).await?;
println!("Outbox delivery acknowledged.");
Ok(())
}
Resilience Features
- Zero Dual-Write Race Conditions: Events are committed in the same database transaction as the state transition.
- Dead-Letter Queue (DLQ): Poison messages that exceed retry limits are diverted to the
DeadLetterSinkwith failure diagnostics. - AsyncAPI 3.0 Generation:
xsm asyncapigenerates standard AsyncAPI specification documents from your statecharts automatically.