Skip to content

Saga Examples

Cross-domain coordination examples from the poker domain.


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

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

  1. Stateless — Each event processed independently
  2. Minimal logic — Just translate fields between domains
  3. Single source — Events from one domain only
  4. Single target — Commands to one domain only
  5. Use destination state — Set sequence from destination’s next_sequence()