Expert knowledge for implementing event sourcing in Composable Rust...
Expert knowledge for implementing event sourcing patterns in Composable Rust - event store design, state reconstruction, version tracking, PostgreSQL integration, and CQRS patterns.
Automatically apply when:
EventStore trait or PostgreSQL event storeState is derived from events, not stored directly.
Events (immutable log) â Replay â Current State (derived)
Instead of updating a record in place, we:
pub trait EventStore: Send + Sync {
/// Append events to the stream
async fn append(
&self,
stream_id: &str,
events: &[SerializedEvent],
expected_version: i64,
) -> Result<(), Error>;
/// Load events from the stream
async fn load(
&self,
stream_id: &str,
from_version: i64,
) -> Result<Vec<SerializedEvent>, Error>;
/// Batch append for efficiency
async fn append_batch(
&self,
batches: &[(String, Vec<SerializedEvent>, i64)],
) -> Result<(), Error>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedEvent {
pub stream_id: String,
pub version: i64,
pub event_type: String,
pub data: Vec<u8>, // bincode-serialized
pub metadata: Option<Vec<u8>>,
pub timestamp: DateTime<Utc>,
}
Key fields:
stream_id: Aggregate identifier (e.g., "order-123")version: Position in stream (for optimistic concurrency)event_type: Discriminator for deserializationdata: Serialized event payload (bincode for performance)timestamp: When event occurredInclude ALL data needed to process the event:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum OrderEvent {
OrderPlaced {
order_id: String,
customer_id: String,
items: Vec<Item>, // â
Full item details
total_amount: Decimal,
timestamp: DateTime<Utc>,
},
OrderCancelled {
order_id: String,
customer_id: String, // â
Redundant but useful
reason: String,
cancelled_at: DateTime<Utc>,
},
}
Benefits:
Performance: See docs/event-design-guidelines.md
When designing events, include:
â Always Include:
pub product_id: String,
pub product_name: String, // â
Denormalized
pub product_sku: String, // â
Denormalized
pub subtotal: Money,
pub tax: Money,
pub total: Money, // â
Pre-calculated
pub shipping_address: Address, // â
Full address, not just address_id
pub items: Vec<LineItem>, // â
Complete details
â Consider Including:
â Don't Include:
Rule of thumb: If a saga or projection needs it, include it in the event.
Include only IDs, fetch details when needed:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum OrderEvent {
OrderPlaced {
order_id: String,
customer_id: String, // â Just ID
item_ids: Vec<String>, // â Just IDs
timestamp: DateTime<Utc>,
},
}
Use when:
Trade-off: Projections are slower (need to join data).
Events are immutable. Handle schema changes with versioning:
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "version")]
pub enum OrderPlacedEvent {
V1 {
order_id: String,
customer_id: String,
},
V2 {
order_id: String,
customer_id: String,
items: Vec<Item}, // New field
},
}
// Conversion logic
impl From<OrderPlacedEvent> for NormalizedOrderPlaced {
fn from(event: OrderPlacedEvent) -> Self {
match event {
OrderPlacedEvent::V1 { order_id, customer_id } => {
Self {
order_id,
customer_id,
items: vec![], // Default for old events
}
}
OrderPlacedEvent::V2 { order_id, customer_id, items } => {
Self { order_id, customer_id, items }
}
}
}
}
Use #[derive(Action)] to auto-generate versioned event types:
use composable_rust_macros::Action;
#[derive(Action, Clone, Debug, Serialize, Deserialize)]
pub enum OrderAction {
#[command]
PlaceOrder { customer_id: String, items: Vec<Item> },
#[event]
OrderPlaced { order_id: String, timestamp: DateTime<Utc> },
#[event]
OrderCancelled { order_id: String, reason: String },
}
// Auto-generated methods:
let event = OrderAction::OrderPlaced { /* ... */ };
assert!(event.is_event()); // â
true
assert_eq!(event.event_type(), "OrderPlaced.v1"); // â
Versioned!
Benefits:
.v1 suffix added by defaultimpl OrderState {
/// Reconstruct state from event stream
pub fn from_events(events: impl Iterator<Item = OrderEvent>) -> Self {
let mut state = Self::default();
for event in events {
state.apply_event(event);
}
state
}
/// Apply a single event (idempotent)
fn apply_event(&mut self, event: OrderEvent) {
match event {
OrderEvent::OrderPlaced { order_id, customer_id, items, timestamp, .. } => {
self.order_id = Some(order_id);
self.customer_id = Some(customer_id);
self.items = items;
self.status = OrderStatus::Placed;
self.created_at = Some(timestamp);
self.version += 1;
}
OrderEvent::OrderCancelled { reason, cancelled_at, .. } => {
self.status = OrderStatus::Cancelled;
self.cancelled_at = Some(cancelled_at);
self.cancellation_reason = Some(reason);
self.version += 1;
}
// Other events...
}
}
}
Pattern: Separate from_events (batch) from apply_event (single). Always increment version.
// Load and reconstruct state
pub async fn load_order(
order_id: &str,
event_store: &impl EventStore,
) -> Result<OrderState, Error> {
// Load events from store
let serialized_events = event_store.load(order_id, 0).await?;
// Deserialize
let events: Vec<OrderEvent> = serialized_events
.into_iter()
.map(|se| bincode::deserialize(&se.data))
.collect::<Result<Vec<_>, _>>()?;
// Reconstruct state
Ok(OrderState::from_events(events.into_iter()))
}
Every write includes expected version. Prevents lost updates:
pub async fn save_order(
order: &OrderState,
events: Vec<OrderEvent>,
event_store: &impl EventStore,
) -> Result<(), Error> {
let order_id = order.order_id.as_ref().ok_or(Error::MissingOrderId)?;
// Serialize events
let serialized: Vec<SerializedEvent> = events
.into_iter()
.enumerate()
.map(|(i, event)| SerializedEvent {
stream_id: order_id.clone(),
version: order.version + i as i64 + 1,
event_type: event_type_name(&event),
data: bincode::serialize(&event)?,
metadata: None,
timestamp: Utc::now(),
})
.collect();
// Append with expected version check
event_store
.append(order_id, &serialized, order.version)
.await?;
Ok(())
}
match event_store.append(stream_id, &events, expected_version).await {
Ok(()) => {
// Success
}
Err(Error::VersionConflict { expected, actual }) => {
// Someone else wrote to this stream
// Options:
// 1. Retry (reload state, re-execute command)
// 2. Fail (let client retry)
// 3. Merge (if safe)
}
Err(e) => {
// Other error
}
}
Pattern: Always include version in append. Handle conflicts explicitly.
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
stream_id TEXT NOT NULL,
version BIGINT NOT NULL,
event_type TEXT NOT NULL,
data BYTEA NOT NULL,
metadata BYTEA,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(stream_id, version)
);
CREATE INDEX idx_events_stream_id ON events(stream_id);
CREATE INDEX idx_events_timestamp ON events(timestamp);
Key points:
UNIQUE(stream_id, version): Enforces version uniquenessBYTEA: Binary data for bincode (efficient)stream_id (lookup) and timestamp (time-based queries)pub async fn append(
&self,
stream_id: &str,
events: &[SerializedEvent],
expected_version: i64,
) -> Result<(), Error> {
let mut tx = self.pool.begin().await?;
// Check current version
let current_version: Option<i64> = sqlx::query_scalar(
"SELECT MAX(version) FROM events WHERE stream_id = $1"
)
.bind(stream_id)
.fetch_optional(&mut *tx)
.await?;
let current_version = current_version.unwrap_or(-1);
if current_version != expected_version {
return Err(Error::VersionConflict {
expected: expected_version,
actual: current_version,
});
}
// Insert events
for event in events {
sqlx::query(
"INSERT INTO events (stream_id, version, event_type, data, metadata, timestamp)
VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(&event.stream_id)
.bind(event.version)
.bind(&event.event_type)
.bind(&event.data)
.bind(&event.metadata)
.bind(event.timestamp)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
Pattern: Use transaction. Check version. Insert all events. Commit atomically.
pub async fn load(
&self,
stream_id: &str,
from_version: i64,
) -> Result<Vec<SerializedEvent>, Error> {
let events = sqlx::query_as::<_, SerializedEvent>(
"SELECT stream_id, version, event_type, data, metadata, timestamp
FROM events
WHERE stream_id = $1 AND version >= $2
ORDER BY version ASC"
)
.bind(stream_id)
.bind(from_version)
.fetch_all(&self.pool)
.await?;
Ok(events)
}
Pattern: Load in order. Support from_version for incremental replay.
For high-throughput scenarios:
pub async fn append_batch(
&self,
batches: &[(String, Vec<SerializedEvent>, i64)],
) -> Result<(), Error> {
let mut tx = self.pool.begin().await?;
for (stream_id, events, expected_version) in batches {
// Check version
let current_version: Option<i64> = sqlx::query_scalar(
"SELECT MAX(version) FROM events WHERE stream_id = $1"
)
.bind(stream_id)
.fetch_optional(&mut *tx)
.await?;
if current_version.unwrap_or(-1) != *expected_version {
return Err(Error::VersionConflict { /* ... */ });
}
// Bulk insert events for this stream
for event in events {
sqlx::query(/* INSERT */)
.bind(&event.stream_id)
// ... other binds
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(())
}
Use when: Processing multiple aggregates in one transaction (saga compensation, batch imports).
// Serialize
let event = OrderEvent::OrderPlaced { /* ... */ };
let data = bincode::serialize(&event)?;
// Deserialize
let event: OrderEvent = bincode::deserialize(&data)?;
Why bincode:
Trade-off: Not human-readable (use metadata or tooling for debugging).
fn event_type_name(event: &OrderEvent) -> String {
match event {
OrderEvent::OrderPlaced { .. } => "OrderPlaced".to_string(),
OrderEvent::OrderCancelled { .. } => "OrderCancelled".to_string(),
// ...
}
}
fn deserialize_event(event_type: &str, data: &[u8]) -> Result<OrderEvent, Error> {
match event_type {
"OrderPlaced" => Ok(bincode::deserialize(data)?),
"OrderCancelled" => Ok(bincode::deserialize(data)?),
_ => Err(Error::UnknownEventType(event_type.to_string())),
}
}
Pattern: Store event type separately for filtering/debugging without deserializing.
Command (Write) â Reducer â Events â Event Store
â
Event Bus
â
Projections (Read Models)
pub trait Projection: Send + Sync {
type Event;
async fn handle(&mut self, event: &Self::Event) -> Result<(), Error>;
}
pub struct OrderSummaryProjection {
database: PostgresDatabase,
}
impl Projection for OrderSummaryProjection {
type Event = OrderEvent;
async fn handle(&mut self, event: &Self::Event) -> Result<(), Error> {
match event {
OrderEvent::OrderPlaced { order_id, customer_id, total_amount, timestamp } => {
sqlx::query(
"INSERT INTO order_summaries (order_id, customer_id, total, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (order_id) DO UPDATE
SET total = EXCLUDED.total"
)
.bind(order_id)
.bind(customer_id)
.bind(total_amount)
.bind(timestamp)
.execute(&self.database.pool)
.await?;
Ok(())
}
// Other events...
_ => Ok(()),
}
}
}
Pattern: Denormalized read models. Idempotent updates (ON CONFLICT DO UPDATE).
See docs/consistency-patterns.md for comprehensive patterns. Quick example:
// Problem: Write to event store, read from projection (eventual consistency)
store.send(OrderAction::PlaceOrder { ... }).await;
let summary = projection_db.get_order_summary(order_id).await?; // â May not be ready
// Solution: Read directly from event store or use consistency tokens
let state = event_store.load_and_reconstruct(order_id).await?; // â
Always current
// â NEVER modify existing events
sqlx::query("UPDATE events SET data = $1 WHERE id = $2")
.execute(&pool)
.await?;
Solution: Events are immutable. Append compensating events instead.
// â NEVER delete events (except for GDPR compliance with care)
sqlx::query("DELETE FROM events WHERE stream_id = $1")
.execute(&pool)
.await?;
Solution: Append a deletion event. Use soft deletes in projections.
// â Don't store current state alongside events
CREATE TABLE events (
...
current_state JSONB -- â Breaks event sourcing!
);
Solution: State is derived. Use snapshots if replay is slow.
// â Appending without version check
event_store.append(stream_id, &events, -1).await?; // â Ignores conflicts
Solution: Always pass expected version. Handle conflicts.
// â Don't update projections synchronously during writes
fn reduce(...) -> Vec<Effect> {
vec![
Effect::Database(SaveEvent),
Effect::Database(UpdateProjection), // â Couples write and read
]
}
Solution: Projections subscribe to event bus asynchronously.
When replay becomes slow, use snapshots:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub stream_id: String,
pub version: i64,
pub state: Vec<u8>, // Serialized state
pub timestamp: DateTime<Utc>,
}
// Save snapshot
pub async fn save_snapshot(
stream_id: &str,
state: &OrderState,
version: i64,
) -> Result<(), Error> {
let data = bincode::serialize(state)?;
sqlx::query(
"INSERT INTO snapshots (stream_id, version, state, timestamp)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (stream_id) DO UPDATE
SET version = EXCLUDED.version, state = EXCLUDED.state"
)
.bind(stream_id)
.bind(version)
.bind(&data)
.execute(&pool)
.await?;
Ok(())
}
// Load with snapshot
pub async fn load_order_optimized(
order_id: &str,
event_store: &impl EventStore,
) -> Result<OrderState, Error> {
// Try to load snapshot
let snapshot = load_snapshot(order_id).await?;
let (mut state, from_version) = if let Some(snap) = snapshot {
(bincode::deserialize(&snap.state)?, snap.version + 1)
} else {
(OrderState::default(), 0)
};
// Load events since snapshot
let events = event_store.load(order_id, from_version).await?;
// Apply remaining events
for event_data in events {
let event: OrderEvent = bincode::deserialize(&event_data.data)?;
state.apply_event(event);
}
Ok(state)
}
When to snapshot:
Frequency: Every 100-1000 events, or on demand.
When implementing event sourcing:
from_events and apply_eventON CONFLICT or check processed IDsFrom benchmarks in docs/event-design-guidelines.md:
| Operation | Fat Events | Thin Events |
|---|---|---|
| Append | 200-300Ξs | 170-250Ξs |
| Replay (100 events) | 2-3ms | 5-7ms (with joins) |
| Projection update | 500-800Ξs | 1-2ms (with joins) |
Recommendation: Use fat events unless you have extreme write throughput requirements (>10k events/sec).
composable-rust-architecture.skill - Core reducer/effect patternscomposable-rust-sagas.skill - Multi-aggregate coordinationdocs/projections.md - Read model patternsdocs/consistency-patterns.md - Read-after-write patternsdocs/event-design-guidelines.md - Fat vs thin eventsRemember: Events are the source of truth. State is derived. Version tracking prevents conflicts. Projections are async and eventually consistent.