| name | preferences-event-sourcing |
| description | Event sourcing patterns including event replay, state reconstruction, and CQRS. Load when implementing event-sourced aggregates or projections. |
Event sourcing
This document provides a unified reference for event sourcing as the natural persistence model for functional domain modeling.
Scott Wlaschin identifies event sourcing as "the most compatible" approach with functional domain modeling, while declaring detailed coverage outside his book's scope.
Kevin Hoffman's "Real World Event Sourcing" provides the complementary operational detail.
This document synthesizes both perspectives into a cohesive framework grounded in algebraic foundations.
For the categorical derivation showing why event sourcing is the natural persistence model for interactive systems, see functional-reactive-programming.md.
Event sourcing stores all state changes as an immutable append-only log of domain events.
Current state is derived by replaying (folding over) events from the beginning.
This approach preserves the complete history of state transitions, enabling time-travel queries, audit trails, and analytical projections.
For theoretical foundations underlying this document, see preferences-theoretical-foundations (its thin SKILL.md and the references/ set, especially references/decide-evolve-lens.md and references/internal-language.md).
For aggregate design and workflow patterns, see domain-modeling.md.
For distributed system coordination, see distributed-systems.md.
The fundamental duality
Traditional persistence stores the current state and discards the history of how we arrived there.
Event sourcing inverts this: we store the history (events) and derive the current state.
This inversion has profound implications for system design.
In algebraic terms, aggregates and events form a duality:
- Events are the free monoid generated by domain actions
- Aggregate state is the catamorphism (fold) over that monoid
- Commands are functions that validate against current state and emit new events
This duality is not an arbitrary design choice but follows from the categorical structure of interactive systems.
See functional-reactive-programming.md#the-core-insight-privileging-change-over-state for how this structure emerges from treating time as user-controlled.
-- The fundamental types
type Events = [DomainEvent] -- free monoid: events with concatenation
type State = ... -- aggregate state
-- State is derived from events via fold
reconstruct :: Events -> State
reconstruct = foldl' apply initialState
-- Commands validate and produce new events
execute :: Command -> State -> Either Error Events
-- The full cycle
handle :: Command -> Events -> Either Error Events
handle cmd events =
let state = reconstruct events
in execute cmd state
This structure directly parallels Wlaschin's Pattern 4 (workflows as pure functions) and Pattern 5 (aggregates as consistency boundaries).
The aggregate receives commands, validates them against current state, and emits events that capture domain facts.
The Decider pattern
The Decider pattern, formalized by Jérémie Chassaing and implemented in libraries like fmodel-rust, provides a pure functional structure for aggregates that emphasizes the duality between command processing and state evolution.
A decider is defined by three pure functions and an initial state:
-- Decider type signature
data Decider cmd event state = Decider
{ decide :: cmd -> state -> Either Error [event]
, evolve :: state -> event -> state
, initialState :: state
, isTerminal :: state -> Bool -- optional
}
The separation between decide (command validation) and evolve (state application) is fundamental.
decide can fail because commands may be invalid given current state; evolve cannot fail because events represent facts that have already occurred.
This asymmetry enforces the principle that validation happens before persistence, never during state reconstruction.
Pure decide and pure evolve
Both decide and evolve are pure functions with no side effects.
decide examines the current state and either rejects the command with an error or produces events describing what happened.
evolve takes state and an event, returning a new state that reflects the event's occurrence.
pub struct Decider<C, S, E, Error = ()> {
pub decide: Box<dyn Fn(&C, &S) -> Result<Vec<E>, Error> + Send + Sync>,
pub evolve: Box<dyn Fn(&S, &E) -> S + Send + Sync>,
pub initial_state: Box<dyn Fn() -> S + Send + Sync>,
}
The purity of evolve is particularly important for state reconstruction.
Given a sequence of events, folding evolve over them must always produce the same final state:
-- State reconstruction as left fold
reconstruct :: [Event] -> State
reconstruct events = foldl evolve initialState events
-- Reconstruction is deterministic
reconstruct events == reconstruct events -- always holds
If evolve could fail or perform I/O, this guarantee would break.
State reconstruction would become non-deterministic, violating the fundamental event sourcing invariant.
Why evolve cannot fail
Events represent facts that occurred in the domain.
The event OrderPlaced means an order was placed; the event PaymentReceived means payment was received.
These are historical facts, not proposals.
The evolve function updates the aggregate's view of current state to reflect that a fact occurred.
It cannot reject a fact; facts are not subject to validation.
Validation happens in decide, before events are emitted and persisted.
-- Decide validates; may fail
decide :: PlaceOrder -> OrderState -> Either OrderError [OrderEvent]
decide cmd state =
case state of
Nothing -> Right [OrderPlaced { ... }]
Just _ -> Left OrderAlreadyExists
-- Evolve applies; cannot fail
evolve :: OrderState -> OrderEvent -> OrderState
evolve Nothing (OrderPlaced details) = Just (Order details)
evolve state (OrderCancelled reason) = fmap (\o -> o { cancelled = True }) state
evolve state _ = state -- unknown events preserve state
If evolve could return Either Error State, we would face an impossible situation: what do we do when reconstructing state from the event log if evolve fails on event 47 out of 1000?
We cannot reject historical events.
The solution is to ensure evolve is total: it handles all event cases, and failure events (if needed) explicitly preserve state.
State reconstruction formula
State reconstruction is defined as a left fold of evolve over the event sequence, starting from initialState.
reconstruct :: Decider cmd event state -> [event] -> state
reconstruct decider events = foldl (evolve decider) (initialState decider) events
This formula has several critical properties:
- Determinism: Given the same events, reconstruction always produces the same state
- Associativity: Folding subsets then combining yields the same result as folding the entire sequence (when state forms a monoid)
- Composability: Multiple deciders can be combined to produce a composite decider over product states
The fold-based reconstruction enables several implementation optimizations:
- Snapshotting: save state at event N, fold only events after N
- Parallel fold: when state forms a monoid, events can be folded in parallel then combined
- Incremental updates: maintain materialized state, fold only new events since last update
Contrast with mutable aggregate patterns
Traditional object-oriented aggregate patterns use mutable apply methods:
impl Aggregate for Order {
fn apply(&mut self, event: OrderEvent) {
match event {
OrderEvent::OrderPlaced { items, total } => {
self.items = items;
self.total = total;
self.status = OrderStatus::Placed;
}
OrderEvent::OrderShipped { shipment_id } => {
self.status = OrderStatus::Shipped(shipment_id);
}
}
}
}
The mutable approach has several drawbacks:
- State reconstruction requires initializing a mutable aggregate then calling
apply in a loop
- Testing requires managing mutable state and ensuring proper reset between tests
- Concurrency requires locks or other synchronization primitives
- Composition is difficult because combining two mutable aggregates produces complex ownership semantics
The Decider pattern uses pure evolve:
let order_decider = Decider {
decide: Box::new(|cmd, state| {
match (cmd, state) {
(PlaceOrder { items }, None) => Ok(vec![OrderPlaced { items }]),
(PlaceOrder { .. }, Some(_)) => Err(OrderAlreadyExists),
(ShipOrder { shipment_id }, Some(order)) if order.status == OrderStatus::Placed => {
Ok(vec![OrderShipped { shipment_id }])
}
_ => Err(InvalidCommand),
}
}),
evolve: Box::new(|state, event| {
match event {
OrderPlaced { items } => Some(Order { items, status: OrderStatus::Placed }),
OrderShipped { shipment_id } => state.map(|o| Order { status: OrderStatus::Shipped(shipment_id), ..o }),
_ => state,
}
}),
initial_state: Box::new(|| None),
};
The pure approach enables:
- State reconstruction as a simple fold with no mutable state
- Trivial testing:
evolve(state, event) is a pure function
- Concurrency without locks:
evolve can be called from any thread
- Composition via
combine(): two deciders yield a decider over (S1, S2)
fmodel-rust as reference implementation
The fmodel-rust library (https://github.com/fraktalio/fmodel-rust) provides the canonical Rust implementation of the Decider pattern.
It is preferred over libraries like cqrs-es or esrs because it enforces pure evolve rather than mutable apply.
Key fmodel-rust idioms:
- State as
Option<T>: None means the aggregate does not exist, Some(T) means it exists with state T
- Failure events for domain rejection: Events like
OrderNotCreated or PaymentNotProcessed represent domain-level rejection, not errors
- Error events preserve state: When
evolve handles a failure event, it returns state.clone() to preserve the previous state
- Algebraic composition via
combine(): Two deciders Decider<C1, E1, S1> and Decider<C2, E2, S2> combine into Decider<Sum<C1, C2>, Sum<E1, E2>, (S1, S2)>
let combined = order_decider.combine(inventory_decider);
type CombinedState = (Option<Order>, Option<Inventory>);
enum CombinedCommand {
OrderCommand(OrderCommand),
InventoryCommand(InventoryCommand),
}
enum CombinedEvent {
OrderEvent(OrderEvent),
InventoryEvent(InventoryEvent),
}
The composition enables building complex aggregates from simpler deciders, preserving the purity and testability of individual components.
Monoidal event composition
Chassaing's key insight is that when aggregate state forms a monoid, evolve can be decomposed into two operations:
-- Traditional evolve
evolve :: State -> Event -> State
-- Decomposed into convert and combine
convert :: Event -> State -- event as state delta
combine :: State -> State -> State -- monoidal operation
-- Relationship: evolve state event = combine state (convert event)
evolve state event = state `combine` convert event
This decomposition is possible when State forms a monoid with combine as the associative operation and initialState as the identity.
The convert function interprets each event as a state delta (change), and combine merges that delta into the current state.
When State forms a monoid
Not all aggregate states form monoids, but many do.
Common examples:
- Sets:
combine = union, initialState = emptySet, convert event = singleton(event.item)
- Maps:
combine = merge (last-write-wins or custom merge), initialState = emptyMap, convert event = singletonMap(event.key, event.value)
- Counters:
combine = (+), initialState = 0, convert event = event.delta
- Append-only logs:
combine = (++), initialState = [], convert event = [event]
When state forms a monoid, the evolve function can be rewritten using convert and combine:
-- Account balance (monoid: (Money, +, 0))
data AccountState = AccountState { balance :: Money }
-- combine is addition
combine :: AccountState -> AccountState -> AccountState
combine s1 s2 = AccountState { balance = s1.balance + s2.balance }
-- initialState is zero
initialState :: AccountState
initialState = AccountState { balance = Money 0 }
-- convert interprets events as deltas
convert :: AccountEvent -> AccountState
convert (Deposited amount) = AccountState { balance = amount }
convert (Withdrawn amount) = AccountState { balance = negate amount }
-- evolve is combine after convert
evolve :: AccountState -> AccountEvent -> AccountState
evolve state event = combine state (convert event)
Parallel fold implications
When state forms a monoid and evolve decomposes into convert and combine, state reconstruction becomes a map-reduce operation:
-- Sequential fold (traditional)
reconstruct :: [Event] -> State
reconstruct = foldl evolve initialState
-- Parallel fold (monoidal state)
reconstructParallel :: [Event] -> State
reconstructParallel events =
let deltas = parMap convert events -- parallel map
in fold combine initialState deltas -- parallel fold
The parallel fold is possible because monoid operations are associative: (a + b) + c == a + (b + c).
We can partition events, compute partial states in parallel, then combine the partials.
This optimization is particularly valuable for long event streams where reconstruction latency is a bottleneck.
Systems like Kafka with compacted logs or EventStoreDB with projections can leverage parallel fold for near-real-time materialization.
Product monoid for aggregate state
When multiple deciders are composed, their states form a product.
If each component state is a monoid, the product is also a monoid with pointwise operations:
-- Two monoids
(S1, combine1, initial1)
(S2, combine2, initial2)
-- Product is a monoid
type ProductState = (S1, S2)
combineProduct :: ProductState -> ProductState -> ProductState
combineProduct (s1a, s2a) (s1b, s2b) = (combine1 s1a s1b, combine2 s2a s2b)
initialProduct :: ProductState
initialProduct = (initial1, initial2)
This structure enables composing deciders while preserving the monoidal optimization.
Each component can be folded in parallel, and the final product state is constructed from the component results.
See preferences-theoretical-foundations references/decide-evolve-lens.md for the algebraic reading of the Decider.
The solid identification lives in evolve: with an initial state it makes State an F-algebra for F X = 1 + Event × X, so state reconstruction is the catamorphism (fold), while decide is the readout leg.
The pair is best read as Moore-machine shaped (evolve the update, decide the output) rather than as a symmetric coalgebra-and-algebra split.
See domain-modeling.md Pattern 5 for aggregate design principles that complement the Decider pattern.
See algebraic-laws.md for the monoid laws and fold laws that underpin monoidal event composition.
Contrasting with CRUD persistence
Traditional CRUD persistence overwrites state in place, losing the history of changes.
Event sourcing preserves the complete sequence of state transitions.
| Aspect | CRUD | Event Sourcing |
|---|
| History | Current snapshot only | Complete change log |
| Audit | Requires separate audit tables | Built-in audit trail |
| Temporal queries | Not possible | Query any point in time |
| Schema alignment | Mutable state ≠ immutable domain | Append-only ≈ immutable model |
| Scalability | Single point of contention | Append-only scales well |
Event sourcing aligns naturally with functional domain modeling because both emphasize immutability.
The event log is a persistent representation of the free monoid structure (events under concatenation).
In CRUD systems, mutable state encourages imperative programming patterns that obscure the sequence of domain actions.
In event-sourced systems, the append-only log makes the history of domain actions explicit and queryable.
Event discovery
The domain events that form the foundation of event-sourced systems emerge through collaborative discovery rather than top-down design.
EventStorming sessions, described in detail in collaborative-modeling.md, produce the raw material from which the event type algebra is constructed.
The translation from orange sticky notes on a workshop wall to discriminated union variants in code represents a formalization process that preserves domain understanding while adding type-level precision.
From sticky notes to algebraic data types
Each orange sticky note in an EventStorming session represents a candidate for a domain event type.
The past-tense naming convention on sticky notes ("Order Placed", "Payment Received", "Shipment Dispatched") maps directly to variant names in a sum type.
The temporal ordering of events on the workshop timeline reveals the chronological constraints that the append-only event log naturally enforces.
An EventStorming board showing events "Order Placed", "Order Shipped", "Order Cancelled" translates to a discriminated union:
data OrderEvent
= OrderPlaced { orderId :: OrderId, customerId :: CustomerId, items :: [OrderItem], timestamp :: Timestamp }
| OrderShipped { orderId :: OrderId, shipmentId :: ShipmentId, carrier :: Carrier, timestamp :: Timestamp }
| OrderCancelled { orderId :: OrderId, reason :: CancellationReason, timestamp :: Timestamp }
The metadata discussed around each sticky note (what triggered it, what information it carries, what subsequent events it enables) becomes the fields in each event record.
Hotspots (pink sticky notes) that mark uncertainty about what data an event contains indicate areas requiring further domain expert conversation before finalizing the type definition.
Commands as event-producing functions
Blue sticky notes representing commands in EventStorming sessions translate to functions that validate preconditions and produce events.
The relationship between a command and the events it produces defines the aggregate's behavior.
A command "Place Order" with business rules about inventory availability and customer credit translates to:
placeOrder :: ValidatedOrderRequest -> AggregateState -> Either OrderError (NonEmpty OrderEvent)
placeOrder request state =
validateInventory request.items
>>= \_ -> validateCustomerCredit request.customerId request.total
>>= \_ -> Right (singleton (OrderPlaced { ... }))
The validation logic encoded in the command handler reflects the business rules discussed during EventStorming.
The NonEmpty return type ensures that successful commands always produce at least one event, maintaining the invariant that commands either fail with an error or advance the aggregate's state through events.
Policies as process managers
Purple sticky notes representing policies ("Whenever X, then Y") translate to event handlers that emit commands in response to events.
In event-sourced systems, these handlers often live in process managers or sagas that coordinate across aggregate boundaries.
A policy "Whenever Order Placed, reserve inventory" becomes a handler subscribed to OrderPlaced events that emits ReserveInventory commands to the Inventory aggregate.
The choreography visible in the EventStorming timeline becomes the event-driven control flow in the implementation.
Aggregate boundaries from event clustering
Yellow sticky notes marking aggregates in EventStorming identify consistency boundaries for event streams.
Each aggregate owns a single event stream and enforces invariants across all events in that stream.
The clustering of events around aggregates during Design Level EventStorming reveals which events must be validated together.
If "Order Line Added" and "Order Line Removed" events must maintain the invariant that orders have at least one line, they belong to the same aggregate and are validated in the same command handler.
The insight from collaborative modeling is that aggregate boundaries are discovered through examining which invariants span which events, not imposed by technical convenience.
EventStorming makes these relationships visible through physical proximity on the workshop board before they become module boundaries in code.
See also
collaborative-modeling.md for detailed EventStorming facilitation patterns, artifact vocabulary, and session types (Big Picture, Process Modelling, Design Level).
discovery-process.md for how event discovery fits into the broader domain discovery workflow.
domain-modeling.md for Pattern 3 (state machines) and Pattern 5 (aggregates) that implement the structures discovered through EventStorming.
Hoffman's ten laws with theoretical grounding
Kevin Hoffman distills event sourcing principles into "laws" that constrain implementation.
Each law corresponds to a theoretical requirement from the algebraic model.
Law 1: Events are immutable
Once an event has been appended to the event log, it cannot be modified or deleted.
Events represent facts that occurred; changing them would violate the historical record.
From the type-theoretic perspective, events form a free monoid: the only operation is append, and there is no inverse.
If events could be modified, the reconstruct function would no longer be referentially transparent.
Law 2: Event schemas are immutable
Event schemas must never change.
Any change to an event schema produces a brand-new event type.
For example, UserCreated_v1 and UserCreated_v2 are distinct types, not versions of the same type.
This law preserves the closed universe assumption required for exhaustive pattern matching.
When the apply function handles events, it must know all possible cases at compile time.
Schema evolution happens through addition of new event types, not modification of existing ones.
-- Schema evolution creates new types
data UserCreated_v1 = UserCreated_v1 { userId :: UserId, email :: Email }
data UserCreated_v2 = UserCreated_v2 { userId :: UserId, email :: Email, tier :: SubscriptionTier }
-- Apply function handles both explicitly
apply :: State -> DomainEvent -> State
apply s (UserCreatedV1 e) = applyUserCreatedV1 s e
apply s (UserCreatedV2 e) = applyUserCreatedV2 s e
Law 3: All data required for a projection must be on the events
Projections cannot inject external context or assumptions about default values.
Every piece of data used by a projector must come from the events themselves.
This law ensures referential transparency of projections.
Given the same event stream, a projection must always produce the same output.
If projectors could access wall-clock time or external databases, this guarantee fails.
Law 4: All projections must stem from events
Every piece of projection data must originate from at least one event.
Projections cannot create data based on information from outside the event stream.
This law ensures projections remain disposable and rebuildable.
If a projection is corrupted or its schema changes, replaying the event log from the beginning must reconstruct it exactly.
Law 5: Different projectors cannot share projections
Projectors must share nothing with other projectors.
Each projector maintains its own projection; it cannot read from or write to projections managed by other projectors.
This law preserves the independence required for parallel projection processing.
If projector A depends on projector B's output, you cannot replay events through A without also keeping B's projection current.
The dependency graph becomes a maintenance nightmare.
Law 6: Applying a failure event must return previous state
When an aggregate encounters a failure condition (invalid command, business rule violation), the failure must be represented as an event.
Applying a failure event to state must return the state unchanged.
This law maintains the state machine interpretation of aggregates.
A failure event captures that something was attempted and rejected, without altering the aggregate's state.
It enables retry semantics and audit trails of failed operations.
Law 7: Work is a side effect
The core primitives of aggregates, projectors, and process managers must never perform "work" (I/O, external calls, mutations).
Work happens only through gateways at the boundary of the event-sourced system.
This law corresponds directly to Wlaschin's principle of isolating effects at boundaries.
The apply function is pure; side effects are pushed to injectors (external input) and notifiers (external output).
Law 8: Never manage more than one flow per process manager
Each process manager is responsible for a single, isolated process.
Its internal state represents an instance of that managed flow (e.g., "Order 421", "Batch 73").
This law prevents cascading failures where one process's error state contaminates another.
It also enables horizontal scaling where process manager instances are independent.
Law 9: Process managers consume events and emit commands
Process managers are the inverse of aggregates: they consume events and emit commands.
This is the choreography mechanism that coordinates multi-aggregate workflows.
In categorical terms, if aggregates are coalgebras (command → events), process managers are algebras (events → commands).
Together they form a closed loop where events trigger commands that produce more events.
Law 10: Aggregates own event streams
Each aggregate owns exactly one event stream.
Events from multiple aggregates should not be interleaved in a single stream.
Cross-aggregate coordination happens through process managers, not shared streams.
This law ensures consistency boundaries remain intact.
An aggregate can enforce invariants only within its own stream.
Interleaved streams create ordering ambiguities that break invariant enforcement.
Mapping ES concepts to functional domain modeling
Event sourcing terminology maps directly to concepts from "Domain Modeling Made Functional."
Commands and workflow inputs (Pattern 4)
In FDM, a workflow is a pure function from validated input to output with events.
The "workflow input" is precisely the event sourcing "command."
-- Wlaschin's workflow type
type Workflow = UnvalidatedInput -> Result WorkflowOutput Error
-- ES command handler has the same shape
type CommandHandler = Command -> Result Events Error
The key insight is that command validation and event emission are the same operation.
A command that passes validation produces events; a command that fails validation produces an error.
Events and domain events (sum types)
FDM models events as a sum type where each case represents a distinct occurrence.
This is exactly how event sourcing models the event log.
-- Domain events as sum type
data OrderEvent
= OrderPlaced { orderId :: OrderId, items :: [OrderItem] }
| OrderShipped { orderId :: OrderId, shipmentId :: ShipmentId }
| OrderCanceled { orderId :: OrderId, reason :: CancellationReason }
-- Event sourcing operations
appendEvents :: StreamId -> [OrderEvent] -> IO ()
readEvents :: StreamId -> IO [OrderEvent]
The sum type structure enables exhaustive pattern matching in apply functions.
Adding a new event case causes compile-time errors until all apply sites are updated.
Aggregates and consistency boundaries (Pattern 5)
FDM defines aggregates as consistency boundaries where invariants must hold after every operation.
Event sourcing adds the temporal dimension: invariants must hold after applying each event.
-- Aggregate with invariant
data Order = Order
{ orderId :: OrderId
, status :: OrderStatus
, items :: [OrderItem]
, total :: Money
-- Invariant: total == sum(items.price)
-- Invariant: status transitions follow state machine
}
-- Apply function maintains invariants
apply :: Order -> OrderEvent -> Order
apply order (OrderPlaced { items }) =
order { items = items, total = sumPrices items, status = Placed }
apply order (OrderShipped { shipmentId }) =
order { status = Shipped shipmentId }
The difference from traditional FDM is that apply functions cannot fail.
Events represent facts that have already occurred; the apply function merely updates the view of current state.
Validation happens in command handlers, before events are emitted.
Projections and derived views (catamorphisms)
Projections in event sourcing are read-optimized views derived from the event log.
In FDM terms, they are the result of folding events with a transformation function.
-- Projection as catamorphism
type Projection a = [DomainEvent] -> a
-- Account balance projection
accountBalance :: [AccountEvent] -> Money
accountBalance = foldl' applyBalance (Money 0)
where
applyBalance bal (Deposited amt) = bal + amt
applyBalance bal (Withdrawn amt) = bal - amt
applyBalance bal _ = bal
-- Leaderboard projection
leaderboard :: [GameEvent] -> [(PlayerId, Score)]
leaderboard events =
events
|> foldl' applyScore Map.empty
|> Map.toList
|> sortBy (Down . snd)
|> take 10
Projections are disposable because they can always be reconstructed from the event log.
This enables schema migration: define a new projection function and replay all events.
Process managers and workflow orchestration
Process managers coordinate multi-step workflows that span aggregate boundaries.
They consume events and emit commands, maintaining internal state to track progress.
In FDM terms, a process manager is a state machine where:
- States represent workflow progress (Created, Processing, Compensating, Completed, Failed)
- Transitions are triggered by domain events
- Actions are commands dispatched to aggregates
data OrderFulfillmentState
= Created
| AwaitingPayment
| PaymentReceived
| Shipped
| Completed
| Failed FailureReason
handleEvent :: OrderFulfillmentState -> DomainEvent -> (OrderFulfillmentState, [Command])
handleEvent Created (OrderPlaced { orderId, items }) =
(AwaitingPayment, [ReserveInventory orderId items])
handleEvent AwaitingPayment (PaymentApproved { orderId }) =
(PaymentReceived, [ShipOrder orderId])
handleEvent PaymentReceived (OrderShipped { orderId }) =
(Completed, [ConfirmInventoryRemoval orderId])
handleEvent _ (PaymentDeclined { orderId }) =
(Failed PaymentFailed, [ReleaseInventory orderId])
Process managers solve the coordination problem that sagas address in distributed systems.
See distributed-systems.md for saga patterns and compensation logic.
Event schema evolution
Event schemas are immutable, but applications evolve.
The resolution is that new event types are added while old event types remain.
Upcasting patterns
When reading events, upcasters transform old event types into a canonical form.
This happens at read time, not at write time, preserving the original event data.
-- Old event type (stored in event log)
data AccountCreated_v1 = AccountCreated_v1
{ userId :: UserId
, name :: String
}
-- New event type (used in application)
data AccountCreated_v2 = AccountCreated_v2
{ userId :: UserId
, firstName :: String
, lastName :: String
, tier :: SubscriptionTier
}
-- Upcaster transforms v1 -> v2 at read time
upcast :: AccountCreated_v1 -> AccountCreated_v2
upcast v1 = AccountCreated_v2
{ userId = v1.userId
, firstName = extractFirstName v1.name
, lastName = extractLastName v1.name
, tier = FreeTrial -- default for legacy accounts
}
Upcasters must be deterministic and side-effect free.
The same v1 event must always produce the same v2 representation.
The backward compatibility trap
It is tempting to add optional fields to existing event schemas, assuming backward compatibility.
This violates the immutability law and leads to subtle bugs.
Consider: AccountCreated gains an optional subscriptionTier field.
Old events have this field absent; new events have it populated.
Application code assumes absence means "free trial."
Later, a code change assumes absence means "enterprise trial."
Now the same events produce different states depending on when they were processed.
The solution is explicit versioning.
AccountCreated_v1 and AccountCreated_v2 are distinct types.
The apply function handles each explicitly; assumptions are visible in code.
Stream migration
When event schema evolution becomes complex, migrating the entire stream may be necessary.
This produces a new stream with upcasted events, leaving the original stream intact.
Stream migration enables:
- "What if" scenario analysis against modified event histories
- Testing schema changes against production-scale data
- Gradual rollout with ability to rollback
The original stream remains the authoritative source; migrated streams are derived artifacts.
CQRS: command query responsibility segregation
CQRS separates the write model (commands, aggregates, events) from the read model (projections, queries).
Event sourcing naturally leads to CQRS because the write model (event log) and read models (projections) have different optimization requirements.
In Decider pattern terms, the write model is the full decider (decide + evolve + initialState), while read models are views (evolve + initialState, without decide).
A view is just a specialized decider that only projects events into a read-optimized representation; it never processes commands.
-- Write model: full decider
type WriteModel cmd event state = Decider
{ decide :: cmd -> state -> Either Error [event]
, evolve :: state -> event -> state
, initialState :: state
}
-- Read model: view pattern (no decide)
type View event projection =
{ evolve :: projection -> event -> projection
, initialState :: projection
}
Process managers and sagas follow the inverse pattern: they react to events by emitting commands.
In categorical terms, this is a saga pattern: react :: event -> [cmd].
Write model optimization
The write model is optimized for:
- Append-only writes (no updates, no deletes)
- Optimistic concurrency via version checks
- Consistency within a single aggregate stream
Event stores like EventStoreDB, Kafka with log compaction disabled, or append-only tables in SQL databases serve this purpose.
Read model optimization
The read model is optimized for:
- Query patterns of specific consumers
- Pre-computed aggregations and joins
- Flexible schema evolution
Projections can use any storage technology: SQL databases for ad-hoc queries, document stores for flexible schemas, key-value stores for low-latency lookups.
The materialized view pattern
Projections are essentially materialized views of the event log.
Unlike database materialized views, they can cross aggregate boundaries because they're derived from events, not source tables.
-- Cross-aggregate projection: customer order history
data CustomerOrderHistory = CustomerOrderHistory
{ customerId :: CustomerId
, customerName :: String -- from CustomerAggregate events
, orders :: [OrderSummary] -- from OrderAggregate events
}
-- Projector handles events from multiple aggregates
projectOrderHistory :: CustomerOrderHistory -> DomainEvent -> CustomerOrderHistory
projectOrderHistory state (CustomerNameChanged { customerId, newName }) =
if customerId == state.customerId
then state { customerName = newName }
else state
projectOrderHistory state (OrderPlaced { orderId, customerId, items }) =
if customerId == state.customerId
then state { orders = state.orders ++ [OrderSummary orderId items] }
else state
The key constraint: projectors cannot communicate with each other.
Each projection is independently derived from the event log.
Event store operational observability
Event-sourced systems have distinct observability requirements beyond standard service metrics.
The append-only event store, the projection/read-model layer, and the aggregate reconstruction path each need targeted instrumentation.
Track event append throughput as events per second by aggregate type, append latency distribution, and store size growth rate for event store health.
Sudden throughput drops or latency spikes indicate storage issues.
For stores with optimistic concurrency, track conflict and retry rates because high conflict rates suggest aggregate boundaries may be too coarse, meaning too many concurrent commands target the same aggregate stream.
Aggregate reconstruction time should be tracked as a histogram by aggregate type.
Long reconstruction times indicate the need for snapshotting.
Monitor snapshot freshness measured as the number of events applied since last snapshot and snapshot creation rate.
If load times grow monotonically for a given aggregate type, the event stream for that type is growing without snapshotting keeping pace.
This metric directly connects to the snapshotting implementation discussed earlier in this document.
Projection lag, the time between event persistence and projection update, is the primary indicator of read-model freshness.
Track lag as a gauge per projection.
Alert on lag exceeding the projection's freshness SLO because stale read models cause user-visible inconsistency in CQRS architectures.
For catch-up projections rebuilding from event zero, track progress as percentage complete and estimated time remaining so operators can predict when the projection will be current.
Track command rejection rates by aggregate type and rejection reason.
Domain validation rejections representing business rule violations are normal and indicate the domain model is working correctly.
Infrastructure rejections such as concurrency conflicts after retries are exhausted or store unavailability indicate operational problems.
Distinguish these categories in metrics labels so that dashboards and alerts can treat them differently.
Events that fail projection processing need a dead letter mechanism.
Track dead letter queue depth and oldest unprocessed event age.
Poison events, those that consistently fail processing, need alerting because they can block an entire projection if the projector processes events sequentially and cannot skip past the failure.
When replaying events for projection rebuild or migration, track replay throughput and estimated completion time.
Replay is an expensive operation that can impact the live event store if it shares the same storage backend.
Instrument both the replay reader and the live write path during replay windows so operators can monitor whether replay is degrading production write latency.
Cross-reference preferences-observability-engineering for the general observability model and preferences-distributed-systems for broader distributed system observability patterns.
Composable commands via free monads
Commands can be implemented as a free monad over the event algebra.
This separates command composition (pure) from command execution (effectful), enabling testable workflows and interpreter-based flexibility.
Commands as pure data
Define commands as a functor representing possible operations.
Lift into the free monad to enable monadic composition without executing effects.
-- Command functor: possible operations
data AccountCommand next
= Open AccountNo Name (Account -> next)
| Close AccountNo (() -> next)
| Debit AccountNo Amount (() -> next)
| Credit AccountNo Amount (() -> next)
instance Functor AccountCommand where
fmap f (Open acc name k) = Open acc name (f . k)
fmap f (Close acc k) = Close acc (f . k)
fmap f (Debit acc amt k) = Debit acc amt (f . k)
fmap f (Credit acc amt k) = Credit acc amt (f . k)
-- Free monad over AccountCommand
type AccountProgram = Free AccountCommand
-- Smart constructors
open :: AccountNo -> Name -> AccountProgram Account
open acc name = liftF (Open acc name id)
close :: AccountNo -> AccountProgram ()
close acc = liftF (Close acc id)
debit :: AccountNo -> Amount -> AccountProgram ()
debit acc amt = liftF (Debit acc amt id)
credit :: AccountNo -> Amount -> AccountProgram ()
credit acc amt = liftF (Credit acc amt id)
This design follows Debasish Ghosh's Chapter 8 pattern: commands become data structures that describe operations without performing them.
The free monad provides monadic bind for sequencing without committing to an execution strategy.
Composing command sequences
Commands compose monadically without executing side effects.
The resulting program is a pure data structure describing the operations.
-- Transfer as pure command composition
transfer :: AccountNo -> AccountNo -> Amount -> AccountProgram ()
transfer from to amount = do
debit from amount
credit to amount
-- Complex workflow: open account and fund it
openAndFund :: AccountNo -> Name -> Amount -> AccountProgram Account
openAndFund accNo name initialDeposit = do
account <- open accNo name
credit accNo initialDeposit
return account
The transfer function constructs a free monad value representing the sequence of operations.
No database calls, no event emissions, no validation yet occur.
The program is just a tree structure encoding the desired computation.
Interpreters for execution
An interpreter traverses the free monad structure, executing effects.
Different interpreters enable different execution contexts (production, testing, simulation).
-- Production interpreter: validates and persists to event store
runProduction :: AccountProgram a -> IO (Either Error a)
runProduction = foldFree interpret
where
interpret :: AccountCommand a -> IO (Either Error a)
interpret (Open accNo name k) = do
result <- validateAndOpen accNo name -- validate business rules
case result of
Left err -> return (Left err)
Right account -> do
appendEvents accNo [AccountOpened accNo name]
return (Right (k account))
interpret (Debit accNo amt k) = do
result <- validateAndDebit accNo amt
case result of
Left err -> return (Left err)
Right () -> do
appendEvents accNo [MoneyDebited accNo amt]
return (Right (k ()))
-- ... other cases
-- Test interpreter: uses in-memory state
runTest :: AccountProgram a -> State (Map AccountNo Account) (Either Error a)
runTest = foldFree interpret
where
interpret :: AccountCommand a -> State (Map AccountNo Account) (Either Error a)
interpret (Open accNo name k) = do
accounts <- get
if Map.member accNo accounts
then return (Left AccountAlreadyExists)
else do
let account = Account accNo name (Money 0)
put (Map.insert accNo account accounts)
return (Right (k account))
-- ... other cases
This separation enables:
- Pure, testable command definitions (programs are data)
- Swappable interpreters for different contexts (production vs test vs audit-only)
- Composition before execution (build complex workflows from simple commands)
- Effect tracking through type-level constraints (interpreters can target different monad stacks)
The pattern unifies command handling with the general principle of effect isolation.
See domain-modeling.md section on module algebras for the broader interpreter pattern across domain services.
See preferences-theoretical-foundations references/internal-language.md for the free constructions and the initial/final duality, and references/decide-evolve-lens.md for the F-algebra and catamorphism reading.
Connection to aggregate command handlers
Free monads provide a compositional layer above traditional aggregate command handlers.
The interpreter is where validation against aggregate invariants occurs:
-- Aggregate command handler (traditional)
handleCommand :: Command -> State -> Either Error [Event]
-- Free monad interpreter calls command handler
interpret :: AccountCommand a -> IO (Either Error a)
interpret cmd = do
events <- loadEvents streamId
let state = reconstruct events
case handleCommand (toCommand cmd) state of
Left err -> return (Left err)
Right newEvents -> do
appendEvents streamId newEvents
return (Right (extractResult newEvents))
This layering preserves the aggregate pattern (commands → validation → events) while adding compositional workflow building.
Process managers and sagas
Long-running processes that span multiple aggregates require coordination.
Process managers (also called sagas) provide this coordination through event choreography.
Process manager structure
A process manager is a state machine that:
- Is instantiated by an inciting event (process start)
- Advances through intermediate events (progress)
- Terminates with completing events (success or failure)
data ProcessState
= NotStarted
| InProgress StepId
| Compensating StepId
| Completed
| Failed FailureReason
data ProcessManager = ProcessManager
{ processId :: ProcessId
, state :: ProcessState
, completedSteps :: [StepId]
, context :: ProcessContext
}
-- Process managers handle events, emit commands
handleEvent :: ProcessManager -> DomainEvent -> (ProcessManager, [Command])
Compensation and rollback
When a step fails, previous steps may need compensation (rollback).
Process managers track completed steps to know what to compensate.
handleEvent pm (StepFailed { stepId }) =
let compensations = reverse (pm.completedSteps)
commands = map CompensateStep compensations
in (pm { state = Compensating stepId }, commands)
Compensation is not transactional rollback; it's semantic rollback.
If Step 1 reserved inventory and Step 2 failed, compensation releases that inventory.
The events remain in the log: ReservationMade, PaymentFailed, ReservationReleased.
Choreography vs orchestration
Choreography: each service reacts to events independently; no central coordinator.
Orchestration: a central process manager directs the workflow.
Prefer choreography when:
- Services have clear ownership boundaries
- Workflows are simple and linear
- Decoupling is more important than visibility
Prefer orchestration when:
- Workflow state needs central visibility
- Compensation logic is complex
- Multiple steps must be coordinated atomically
Most real systems use a hybrid: choreography for simple flows, orchestration for complex multi-step processes.
Injectors and notifiers: the boundary layer
Event-sourced systems interact with the external world through gateways.
Hoffman distinguishes two gateway types:
Injectors
Injectors convert external inputs into domain events or commands.
They are the input boundary of the event-sourced system.
Examples:
- HTTP handlers that parse requests into commands
- Message consumers that transform external events into domain events
- Scheduled jobs that inject time-based events (e.g.,
WeekCompleted)
Injectors must never contain business logic.
They translate, validate structure (not business rules), and dispatch.
Notifiers
Notifiers react to domain events by performing external side effects.
They are the output boundary of the event-sourced system.
Examples:
- Send email when
OrderShipped event occurs
- Update external cache when projection changes
- Publish events to external message broker
Notifiers must be idempotent: receiving the same event twice produces the same external effect.
This is necessary because event delivery may be at-least-once.
When to use event sourcing
Event sourcing adds complexity.
It is not the default choice for all applications.
Strong indicators for event sourcing
Use event sourcing when:
- Audit trail is a hard requirement: financial, medical, legal domains where you must answer "what happened and when" years later.
- Temporal queries are valuable: "What was the account balance on January 15?" requires reconstructing historical state.
- Business rules evolve frequently: new projections can be created without migrating existing data.
- Event-driven architecture already exists: if you're publishing events anyway, event sourcing provides consistency between state and events.
- Debug and replay capability matters: reproducing bugs by replaying events is powerful.
Strong indicators against event sourcing
Avoid event sourcing when:
- Simple CRUD suffices: most applications don't need historical queries.
- Events are low-value: if you wouldn't use the event history, don't pay the complexity cost.
- Schema evolution is uncertain: event versioning requires discipline; unclear domains make this hard.
- Team lacks distributed systems expertise: event sourcing failure modes are subtle.
- Latency requirements are extreme: reconstructing state from events adds latency compared to reading current state.
The hybrid approach
Many successful systems use event sourcing selectively:
- Event-source the coordination layer (workflows, sagas, audit-critical entities)
- Use traditional persistence for domain aggregates where history doesn't matter
- Project events into read-optimized stores for queries
This hybrid model captures event sourcing benefits where they matter while avoiding unnecessary complexity elsewhere.
Implementation considerations
This section addresses practical concerns that arise in production event-sourced systems.
Optimistic concurrency
Multiple command handlers may attempt to modify the same aggregate concurrently.
Without coordination, later events may be based on stale state.
Event stores use version numbers for optimistic concurrency control:
appendEvents :: StreamId -> ExpectedVersion -> [Event] -> IO (Either ConcurrencyError ActualVersion)
If the expected version doesn't match the actual version, the append fails.
The command handler must reload events and retry.
Snapshotting
Aggregates with long event histories become slow to reconstruct.
Snapshotting periodically saves the current state, so reconstruction starts from the snapshot.
data Snapshot = Snapshot
{ snapshotVersion :: Version
, snapshotState :: State
}
reconstruct :: Maybe Snapshot -> [Event] -> State
reconstruct Nothing events = foldl' apply initialState events
reconstruct (Just snap) events =
let newEvents = filter (\e -> e.version > snap.snapshotVersion) events
in foldl' apply snap.snapshotState newEvents
Snapshots are derived artifacts, not the source of truth.
If the snapshot format changes or becomes corrupted, regenerate from events.
Snapshot implementation details
Snapshotting accelerates reconstruction by saving periodic aggregate state.
Reconstruction then folds only events after the snapshot, avoiding replaying the entire history.
-- Snapshot policy: every N events
snapshotFrequency :: Int
snapshotFrequency = 100
-- Save snapshot after appending events
appendEventsWithSnapshot :: StreamId -> [Event] -> IO ()
appendEventsWithSnapshot streamId newEvents = do
currentVersion <- appendEvents streamId newEvents
when (currentVersion `mod` snapshotFrequency == 0) $ do
allEvents <- loadEvents streamId
let state = foldl' apply initialState allEvents
saveSnapshot streamId (Snapshot currentVersion state)
-- Load with snapshot optimization
loadAggregate :: StreamId -> IO State
loadAggregate streamId = do
maybeSnapshot <- loadLatestSnapshot streamId
case maybeSnapshot of
Nothing -> do
events <- loadEvents streamId
return (foldl' apply initialState events)
Just snap -> do
events <- loadEventsSince streamId (snapshotVersion snap)
return (foldl' apply (snapshotState snap) events)
Key properties:
- Snapshots are derived artifacts, not source of truth (events remain authoritative)
- Snapshots can be regenerated from the event log if corrupted or schema changes
- Snapshot frequency balances reconstruction speed vs storage cost
- Version numbers ensure correct event filtering (only fold events after snapshot)
- Snapshot failures must not prevent event appending (snapshots are optimization, not correctness requirement)
Snapshot storage can be separate from event storage.
Some systems store snapshots in fast key-value stores while events remain in durable append-only logs.
This separation enables snapshot deletion without affecting event retention policies.
Event ordering and causality
Within a single stream, events are totally ordered by append order.
Across streams, only causal ordering is guaranteed (if you read an event, you'll read its causes first).
For cross-stream ordering requirements:
- Use timestamps with care (clock skew exists)
- Use logical clocks (Lamport timestamps, vector clocks) for causal ordering
- Accept that "global order" is undefined in distributed systems
Idempotency and deduplication
At-least-once delivery means projectors and process managers may receive duplicate events.
Handlers must be idempotent: processing the same event twice produces the same result.
Common strategies:
- Track processed event IDs and skip duplicates
- Design handlers so reprocessing is inherently safe (add is idempotent for sets)
- Use database constraints to reject duplicate writes
Relationship to other patterns
Event sourcing intersects with several architectural patterns covered in related documents.
Domain-driven design
Event sourcing is the natural persistence mechanism for DDD aggregates.
Events capture the domain language; aggregates enforce business invariants.
See domain-modeling.md for aggregate design patterns.
Hexagonal architecture
Event sourcing fits cleanly into hexagonal/onion architecture:
- Domain core: aggregates, events, apply functions (pure)
- Application layer: command handlers, process managers
- Infrastructure layer: event stores, projectors, gateways
See architectural-patterns.md for hexagonal architecture details.
Reactive systems
Event-sourced systems are inherently event-driven and message-passing.
They naturally support backpressure (consumers control consumption rate) and resilience (events can be replayed after failures).
See distributed-systems.md for reactive system patterns.
See also
domain-modeling.md - Aggregate design, state machines, workflow patterns (Wlaschin's FDM)
- preferences-theoretical-foundations - algebraic duality, catamorphisms, free monoids, and the capability-interface primitive (see its references/decide-evolve-lens.md and references/internal-language.md)
distributed-systems.md - CQRS, saga patterns, authority models
railway-oriented-programming.md - Result types, error handling in command validation
architectural-patterns.md - Hexagonal architecture, effect isolation
schema-versioning.md - General schema evolution strategies (apply to event schemas)
rust-development/12-distributed-systems.md - Rust-specific ES implementation patterns