Skip to content

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.


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.


Every saga follows this pattern:

  1. Receive EventBook with domain events
  2. Filter for events this saga cares about
  3. Extract data needed to build commands or facts
  4. Create CommandBooks or FactBooks targeting other aggregates
  5. 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.


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),
)
],
)

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 commands

Each CommandBook targets a different aggregate root. The framework dispatches them independently — if one fails, others may still succeed (handle via compensation).


When a saga command is rejected (e.g., table is full), Angzarr routes a Notification back to the source aggregate:

illustrative - compensation flow diagram
1. Player emits FundsReserved
2. Saga issues JoinTable → Table
3. Table rejects: "table_full"
4. Notification sent to Player
5. Player handles rejection → emits FundsReleased

The 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(),
)

Sagas can emit either commands or facts to target domains:

OutputCan RejectUse Case
CommandYesRequest action that may fail validation
FactNoAssert 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.

illustrative
# 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.


Sagas MUST set correct sequence numbers on commands. The framework validates sequences for optimistic concurrency.

The saga coordinator uses a two-phase flow to provide target domain context:

illustrative - two-phase flow
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
illustrative
# 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 command
CommandPage(sequence=target_seq, command=cmd_any)

Commands with incorrect sequences are rejected, triggering automatic retry with fresh state.


CQRS/ES architectures cannot provide distributed ACID transactions. Instead:

  1. Design for success: Saga commands should not fail under normal operation
  2. Handle exceptions: Compensation flow handles the rare rejection cases
  3. Eventual consistency: Accept that cross-domain operations settle over time

If saga commands frequently fail, reconsider your domain boundaries.