Sagas

The dual of an aggregate — React<E> consumes upstream events and produces the saga's own events plus outgoing intents.

Sagas

A saga (a.k.a. process manager) coordinates a long-running process that spans more than one aggregate — "when an order is placed, take payment; when payment settles, arrange shipping." An aggregate never touches another aggregate's stream directly; a saga is how you connect them, reacting to events and issuing new intents.

In mnesis a saga is the exact dual of an aggregate:

AggregateSaga
Handle<C> — decide from a commandReact<E> — react to an event
command taken by value (built fresh)event taken by borrow (read from a stream)
produces its own eventsproduces its own events plus outgoing intents

Everything else — AggregateState, Events, versions, replay — is reused unchanged. A saga is an aggregate with two extra capabilities.

The traits

rust
pub trait Saga: Aggregate {
    type CorrelationKey: Clone + Eq + Hash + Send + Sync + Debug + 'static;
    type Command: Message;                                   // outgoing intent vocabulary
    fn intent_for(event: &EventOf<Self>) -> Option<Self::Command>;
}

pub trait React<E: DomainEvent, const N: usize = 0>: Saga {
    fn correlate(event: &E) -> Option<Self::CorrelationKey>;
    fn react(state: &Self::State, event: &E)
        -> Result<Option<Events<EventOf<Self>, N>>, Self::Error>;
}
  • correlate — pure routing. Given an incoming upstream event, which saga instance does it belong to? None means "not routed to this saga at all."
  • react — the decision. Given the saga's current state and an upstream event, produce the saga's own events, or Ok(None) to ignore it (a routed no-op — a duplicate, or a step already passed).
  • intent_for — projects the saga's own events into outgoing commands (intents), 1:1.

You get two levels of "ignore": correlate -> None (not routed) and react -> Ok(None) (routed, but nothing to do).

Model A — intents are a projection of events, never a second output

This is the load-bearing design choice. A saga's outgoing commands are not a separate thing react returns. react produces only the saga's own events; those events are folded into state and projected to intents via intent_for.

text
upstream event ─► react ─► saga's own events ─┬─► apply  (state advances)
                                              └─► intent_for  (outgoing commands)

Because an intent can only come from a recorded event, a dispatched command can never drift from history. There is no way to "send a command but forget to record why." The store-side saga repository even hands back intents as sealed capability tokens — possessing one is proof the triggering event is durable.

What mnesis provides, and what it doesn't

mnesis gives you the saga primitive — the traits, AggregateRoot::react dispatch (the dual of handle), and a store-side bounded repository (react_and_save / dispatch). It does not give you the runtime: the loop that subscribes to upstream streams, resolves instances, and dispatches the enriched commands to their target aggregates is the runtime's job (in the Mnesis + Agency product, that is Agency).

mnesis keeps the outgoing Command a thin intent — enriching it into a target aggregate's fat command is enrichment the runtime does, not the kernel.

Testing sagas

The testing feature ships a SagaFixture dual to the aggregate fixture: given (replay history) → when(&event) (drives react and projects intents) → then_expect_events / then_expect_commands / then_expect_ignored / then_expect_error.

Where to next