Sagas
A saga is a message translator that bridges bounded contexts. When an event occurs in one domain, a saga reacts by issuing commands to another domain.
Sagas are the bridge between domains. Each domain has its own aggregate, but aggregates don’t communicate directly. Instead, sagas listen to events from one domain and generate commands for other domains.
Single Domain Subscription
Section titled “Single Domain Subscription”Sagas should subscribe to ONE domain.
Multi-domain subscription creates:
- Ordering ambiguity (which event triggers first?)
- Duplicate processing
- Race conditions
If you need multi-domain subscription, use a Process Manager.
Saga Pattern
Section titled “Saga Pattern”Every saga follows this pattern:
- Receive EventBook with domain events
- Filter for events this saga cares about
- Extract data needed to build commands or facts
- Create CommandBooks or FactBooks targeting other aggregates
- Return commands/facts (which Angzarr dispatches)
flowchart LR
subgraph DomainA[Player Domain]
Input[EventBook<br/>FundsReserved]
end
subgraph Saga[Saga Processing]
Filter[1. Filter events]
Extract[2. Extract data]
Build[3. Build command]
Filter --> Extract --> Build
end
subgraph DomainB[Table Domain]
Output[CommandBook<br/>JoinTable]
end
Input --> Filter
Build --> Output
style DomainB stroke-dasharray: 5 5
style Output stroke-dasharray: 5 5
The dashed domain represents any target domain—sagas always bridge from one domain to another.
Example: Table-Hand Saga
Section titled “Example: Table-Hand Saga”When a table starts a hand, issue a DealCards command to the hand domain. Sagas are classes extending the client library’s Saga base (or the language’s equivalent); handler methods are registered by decorator / attribute / macro.
@saga(name="saga-table-hand", source="table", target="hand")class TableHandSaga: """Saga that translates HandStarted events to DealCards commands."""
@handles(table.HandStarted) def handle_hand_started( self, event: table.HandStarted, destinations: Destinations, ) -> types.CommandBook: """Translate HandStarted -> DealCards.""" dest_seq = destinations.sequence_for("hand") if destinations else 0 dest_seq = dest_seq if dest_seq is not None else 0
players = [ hand.PlayerInHand( player_root=seat.player_root, position=seat.position, stack=seat.stack, ) for seat in event.active_players ]
deal_cards = hand.DealCards( table_root=event.hand_root, hand_number=event.hand_number, game_variant=event.game_variant, dealer_position=event.dealer_position, small_blind=event.small_blind, big_blind=event.big_blind, ) deal_cards.players.extend(players)
return types.CommandBook( cover=types.Cover( domain="hand", root=types.UUID(value=event.hand_root), ), pages=[ types.CommandPage( header=types.PageHeader(sequence=dest_seq), command=_pack(deal_cards), ) ], )Splitter Pattern
Section titled “Splitter Pattern”When one event should trigger commands to multiple different aggregates, return multiple CommandBook entries — one per target aggregate root. This is the splitter pattern.
Example: When a table settles, distribute payouts to multiple players:
@saga(name="saga-table-player-splitter", source="table", target="player")class TableSettledSplitterSaga: """Splits one TableSettled event into multiple TransferFunds commands."""
@handles(_TableSettled) def handle_table_settled( self, event, destinations: Destinations ) -> list[types.CommandBook]: target_seq = destinations.sequence_for("player") if destinations else 0 target_seq = target_seq if target_seq is not None else 0
commands: list[types.CommandBook] = [] for payout in event.payouts: cmd = _TransferFunds( table_root=event.table_root, amount=payout.amount, ) commands.append( types.CommandBook( cover=types.Cover( domain="player", root=types.UUID(value=payout.player_root), ), pages=[ types.CommandPage( header=types.PageHeader(sequence=target_seq), command=_pack(cmd), ) ], ) ) return commandsEach CommandBook targets a different aggregate root. The framework dispatches them independently — if one fails, others may still succeed (handle via compensation).
Compensation Flow
Section titled “Compensation Flow”When a saga command is rejected (e.g., table is full), Angzarr routes a Notification back to the source aggregate:
1. Player emits FundsReserved │ ▼2. Saga issues JoinTable → Table │ ▼3. Table rejects: "table_full" │ ▼4. Notification sent to Player │ ▼5. Player handles rejection → emits FundsReleasedThe source aggregate decides how to compensate based on the rejection reason.
def handle_table_join_rejected( notification: types.Notification, state: PlayerState,) -> player.FundsReleased | None: """Handle JoinTable rejection by releasing reserved funds.
Returns the FundsReleased event directly (packed into an EventBook by the router) or ``None`` if no reservation exists for the rejected table. """ rejection = types.RejectionNotification() if notification.HasField("payload"): notification.payload.Unpack(rejection)
table_root = b"" if rejection.HasField("rejected_command"): rc = rejection.rejected_command if rc.HasField("cover") and rc.cover.HasField("root"): table_root = rc.cover.root.value
table_key = table_root.hex() reserved_amount = state.table_reservations.get(table_key, 0) if reserved_amount == 0: return None new_reserved = state.reserved_funds - reserved_amount new_available = state.bankroll - new_reserved
return player.FundsReleased( amount=poker_types.Currency(amount=reserved_amount, currency_code="CHIPS"), table_root=table_root, new_available_balance=poker_types.Currency( amount=new_available, currency_code="CHIPS" ), new_reserved_balance=poker_types.Currency( amount=new_reserved, currency_code="CHIPS" ), released_at=now(), )Commands vs Facts
Section titled “Commands vs Facts”Sagas can emit either commands or facts to target domains:
| Output | Can Reject | Use Case |
|---|---|---|
| Command | Yes | Request action that may fail validation |
| Fact | No | Assert external reality the aggregate must accept |
Commands go through the target aggregate’s @handles command methods and can be rejected. Facts bypass validation entirely — the aggregate must accept them.
# Command: request an action (can be rejected)def handle_hand_started(event: HandStarted, destinations: list[EventBook]): return CommandBook( cover=Cover(domain="player", root=event.player_id), pages=[CommandPage(sequence=dest_seq, command=DeductBlinds(amount=50))], )
# Fact: assert reality (cannot be rejected)def handle_turn_assigned(event: TurnAssigned, destinations: list[EventBook]): return FactBook( cover=Cover(domain="player", root=event.player_id, external_id=f"turn:{event.hand_id}"), pages=[FactPage(fact=FactSequence(source="hand"), event=YourTurn(hand_id=event.hand_id))], )Use facts when the source domain has authority the target must accept—tournament seating, dealer rulings, external system confirmations. See Commands vs Facts for details.
Sequence Handling
Section titled “Sequence Handling”Sagas MUST set correct sequence numbers on commands. The framework validates sequences for optimistic concurrency.
Two-Phase Saga Flow
Section titled “Two-Phase Saga Flow”The saga coordinator uses a two-phase flow to provide target domain context:
Phase 1: Coordinator receives source event ↓ Coordinator calls prepare handler to get destination covers ↓ Coordinator fetches EventBooks for declared destinations ↓Phase 2: Coordinator invokes your saga handler with: - Source event - Destination EventBooks (target domain states)The SagaContext contains the target domain(s) aggregate states—not the source domain. This allows your saga to:
- Get correct sequence numbers for optimistic concurrency
- Make routing decisions based on target state
- Avoid stale sequence errors
# SagaContext contains target domain state (table), not source (player)# Fetched by coordinator before invoking your handler
target_seq = context.get_sequence("table", table_id)
# Use in commandCommandPage(sequence=target_seq, command=cmd_any)Commands with incorrect sequences are rejected, triggering automatic retry with fresh state.
Transactional Guarantees
Section titled “Transactional Guarantees”CQRS/ES architectures cannot provide distributed ACID transactions. Instead:
- Design for success: Saga commands should not fail under normal operation
- Handle exceptions: Compensation flow handles the rare rejection cases
- Eventual consistency: Accept that cross-domain operations settle over time
If saga commands frequently fail, reconsider your domain boundaries.
Further Reading
Section titled “Further Reading”- Message Translator Pattern — The pattern sagas implement
- Messages between Bounded Context — Cross-domain communication
- Choreography vs Orchestration — Sagas use choreography; Process Managers use orchestration
Next Steps
Section titled “Next Steps”- Commands vs Facts — When to emit facts instead of commands
- Projectors — Building read models
- Process Managers — Stateful multi-domain coordination
- Error Recovery — DLQ, retry, compensation details
- Compensation Flow — Detailed compensation patterns