Saga Examples
Cross-domain coordination examples from the poker domain.
Saga: Table → Hand
Section titled “Saga: Table → Hand”When a table starts a hand, the table-hand saga translates the table domain’s HandStarted event into a hand domain DealCards command.
Sagas are classes with @prepares / @handles decorators — the same OO pattern as aggregates. All six languages use this.
@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), ) ], )Saga: Compensation
Section titled “Saga: Compensation”When a saga-issued command is rejected, the source aggregate receives a Notification and must emit compensation events. Use @rejected decorators to register handlers — no if/else chains needed.
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(), )Saga Principles
Section titled “Saga Principles”- Stateless — Each event processed independently
- Minimal logic — Just translate fields between domains
- Single source — Events from one domain only
- Single target — Commands to one domain only
- Use destination state — Set sequence from destination’s next_sequence()
Next Steps
Section titled “Next Steps”- Aggregates — Handler examples
- Process Managers — Multi-domain orchestration
- Testing — Saga testing with Gherkin