| name | composable-rust-event-sourcing |
| description | Expert knowledge for implementing event sourcing in Composable Rust. Use when implementing event-sourced aggregates, working with EventStore trait or PostgreSQL, designing event schemas, implementing state reconstruction, dealing with optimistic concurrency and version tracking, or questions about event sourcing, CQRS, and persistence patterns. |
Composable Rust Event Sourcing Expert
Expert knowledge for implementing event sourcing patterns in Composable Rust - event store design, state reconstruction, version tracking, PostgreSQL integration, and CQRS patterns.
When to Use This Skill
Automatically apply when:
- Implementing event-sourced aggregates
- Working with
EventStore trait or PostgreSQL event store
- Designing event schemas or event types
- Implementing state reconstruction from events
- Dealing with optimistic concurrency or version tracking
- Questions about event sourcing, CQRS, or persistence
Event Sourcing Fundamentals
Core Principle
State is derived from events, not stored directly.
Events (immutable log) → Replay → Current State (derived)
Instead of updating a record in place, we:
- Append events to an immutable log
- Reconstruct state by replaying events
- Use projections for read models (CQRS)
Benefits
- Complete audit trail: Every state change is recorded
- Time travel: Reconstruct state at any point in time
- Event replay: Fix bugs by replaying events with corrected logic
- Projections: Multiple read models from same event stream
- Debugging: See exactly what happened and when
Trade-offs
- Complexity: More complex than CRUD
- Storage: Events accumulate (mitigate with snapshots)
- Performance: Replay can be slow (mitigate with caching/snapshots)
- Schema evolution: Events are immutable, need careful versioning
EventStore Trait Pattern
Trait Definition
pub trait EventStore: Send + Sync {
async fn append(
&self,
stream_id: &str,
events: &[SerializedEvent],
expected_version: i64,
) -> Result<(), Error>;
async fn load(
&self,
stream_id: &str,
from_version: i64,
) -> Result<Vec<SerializedEvent>, Error>;
async fn append_batch(
&self,
batches: &[(String, Vec<SerializedEvent>, i64)],
) -> Result<(), Error>;
}
SerializedEvent Pattern
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedEvent {
pub stream_id: String,
pub version: i64,
pub event_type: String,
pub data: Vec<u8>,
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 deserialization
data: Serialized event payload (bincode for performance)
timestamp: When event occurred
Event Design Patterns
Pattern 1: Fat Events (Recommended for Most Cases)
Include 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>,
total_amount: Decimal,
timestamp: DateTime<Utc>,
},
OrderCancelled {
order_id: String,
customer_id: String,
reason: String,
cancelled_at: DateTime<Utc>,
},
}
Benefits:
- Self-contained (no need to join with other data)
- Consumers don't need access to other aggregates
- Projections are simple and fast
- Safe from schema changes in other aggregates
Performance: See docs/event-design-guidelines.md
- Fat events: ~15-20% slower append (still only 200-300μs)
- Fat events: ~40% faster replay (no joins needed)
- Recommendation: Use fat events unless you have extreme write performance needs
Data Inclusion Checklist
When designing events, include:
✅ Always Include:
- Identifiers: All relevant IDs (order_id, customer_id, product_id, etc.)
- Core data: The actual data that changed
- Metadata: timestamp, version, correlation_id
- Denormalized lookups: Names/SKUs, not just IDs
pub product_id: String,
pub product_name: String,
pub product_sku: String,
- Pre-calculated values: Totals, tax, subtotals
pub subtotal: Money,
pub tax: Money,
pub total: Money,
- Complete nested objects: Full addresses, line items
pub shipping_address: Address,
pub items: Vec<LineItem>,
❓ Consider Including:
- Causation data: Why did this happen? (reason, triggered_by)
- Previous state: For debugging (previous_status, previous_total)
❌ Don't Include:
- Sensitive data: Credit cards, SSNs (use tokens instead)
- Large binary data: Store separately, include URL
- Computed aggregations: These go out of date immediately
Rule of thumb: If a saga or projection needs it, include it in the event.
Pattern 2: Thin Events (For High-Write Scenarios)
Include only IDs, fetch details when needed:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum OrderEvent {
OrderPlaced {
order_id: String,
customer_id: String,
item_ids: Vec<String>,
timestamp: DateTime<Utc>,
},
}
Use when:
- Extreme write performance requirements
- Events contain large nested structures
- You have guaranteed access to reference data
Trade-off: Projections are slower (need to join data).
Pattern 3: Event Versioning
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},
},
}
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![],
}
}
OrderPlacedEvent::V2 { order_id, customer_id, items } => {
Self { order_id, customer_id, items }
}
}
}
}
Developer Experience: Automatic Event Types
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 },
}
let event = OrderAction::OrderPlaced { };
assert!(event.is_event());
assert_eq!(event.event_type(), "OrderPlaced.v1");
Benefits:
- Zero boilerplate: No manual event_type() implementation
- Automatic versioning:
.v1 suffix added by default
- Type safety: Compile-time distinction between commands/events
State Reconstruction Pattern
Replay from Events
impl OrderState {
pub fn from_events(events: impl Iterator<Item = OrderEvent>) -> Self {
let mut state = Self::default();
for event in events {
state.apply_event(event);
}
state
}
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;
}
}
}
}
Pattern: Separate from_events (batch) from apply_event (single). Always increment version.
With EventStore
pub async fn load_order(
order_id: &str,
event_store: &impl EventStore,
) -> Result<OrderState, Error> {
let serialized_events = event_store.load(order_id, 0).await?;
let events: Vec<OrderEvent> = serialized_events
.into_iter()
.map(|se| bincode::deserialize(&se.data))
.collect::<Result<Vec<_>, _>>()?;
Ok(OrderState::from_events(events.into_iter()))
}
Optimistic Concurrency Pattern
Version Tracking
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)?;
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();
event_store
.append(order_id, &serialized, order.version)
.await?;
Ok(())
}
Concurrency Conflict Handling
match event_store.append(stream_id, &events, expected_version).await {
Ok(()) => {
}
Err(Error::VersionConflict { expected, actual }) => {
}
Err(e) => {
}
}
Pattern: Always include version in append. Handle conflicts explicitly.
PostgreSQL Event Store Implementation
Schema Pattern
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 uniqueness
BYTEA: Binary data for bincode (efficient)
- Indices on
stream_id (lookup) and timestamp (time-based queries)
Append Implementation
pub async fn append(
&self,
stream_id: &str,
events: &[SerializedEvent],
expected_version: i64,
) -> Result<(), Error> {
let mut tx = self.pool.begin().await?;
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,
});
}
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.
Load Implementation
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.
Batch Append Pattern
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 {
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 { });
}
for event in events {
sqlx::query()
.bind(&event.stream_id)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(())
}
Use when: Processing multiple aggregates in one transaction (saga compensation, batch imports).
Serialization Patterns
Bincode for Events (Recommended)
let event = OrderEvent::OrderPlaced { };
let data = bincode::serialize(&event)?;
let event: OrderEvent = bincode::deserialize(&data)?;
Why bincode:
- 5-10x faster than JSON
- 30-70% smaller payloads
- Type-safe (compile-time checks)
Trade-off: Not human-readable (use metadata or tooling for debugging).
Event Type Discriminator Pattern
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.
CQRS Pattern (Command Query Responsibility Segregation)
Commands → Events → Projections
Command (Write) → Reducer → Events → Event Store
↓
Event Bus
↓
Projections (Read Models)
Projection Pattern
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(())
}
_ => Ok(()),
}
}
}
Pattern: Denormalized read models. Idempotent updates (ON CONFLICT DO UPDATE).
Read-After-Write Consistency
See docs/consistency-patterns.md for comprehensive patterns. Quick example:
store.send(OrderAction::PlaceOrder { ... }).await;
let summary = projection_db.get_order_summary(order_id).await?;
let state = event_store.load_and_reconstruct(order_id).await?;
Common Anti-Patterns to Avoid
❌ Anti-Pattern 1: Updating Events
sqlx::query("UPDATE events SET data = $1 WHERE id = $2")
.execute(&pool)
.await?;
Solution: Events are immutable. Append compensating events instead.
❌ Anti-Pattern 2: Deleting Events
sqlx::query("DELETE FROM events WHERE stream_id = $1")
.execute(&pool)
.await?;
Solution: Append a deletion event. Use soft deletes in projections.
❌ Anti-Pattern 3: State in Event Store
CREATE TABLE events (
...
current_state JSONB -- ❌ Breaks event sourcing!
);
Solution: State is derived. Use snapshots if replay is slow.
❌ Anti-Pattern 4: Not Checking Versions
event_store.append(stream_id, &events, -1).await?;
Solution: Always pass expected version. Handle conflicts.
❌ Anti-Pattern 5: Synchronous Projections in Write Path
fn reduce(...) -> Vec<Effect> {
vec![
Effect::Database(SaveEvent),
Effect::Database(UpdateProjection),
]
}
Solution: Projections subscribe to event bus asynchronously.
Snapshot Pattern (For Performance)
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>,
pub timestamp: DateTime<Utc>,
}
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(())
}
pub async fn load_order_optimized(
order_id: &str,
event_store: &impl EventStore,
) -> Result<OrderState, Error> {
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)
};
let events = event_store.load(order_id, from_version).await?;
for event_data in events {
let event: OrderEvent = bincode::deserialize(&event_data.data)?;
state.apply_event(event);
}
Ok(state)
}
When to snapshot:
- Stream has >1000 events
- Replay takes >100ms
- State is frequently accessed
Frequency: Every 100-1000 events, or on demand.
Quick Reference Checklist
When implementing event sourcing:
Performance Guidelines
From 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).
See Also
- Architecture:
composable-rust-architecture.skill - Core reducer/effect patterns
- Sagas:
composable-rust-sagas.skill - Multi-aggregate coordination
- Projections:
docs/projections.md - Read model patterns
- Consistency:
docs/consistency-patterns.md - Read-after-write patterns
- Guidelines:
docs/event-design-guidelines.md - Fat vs thin events
Remember: Events are the source of truth. State is derived. Version tracking prevents conflicts. Projections are async and eventually consistent.