Skip to main content

Saga Examples

Cross-domain coordination examples from the poker domain.


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.

⍼ Angzarr supports two saga implementation styles:

StyleDescriptionBest For
FunctionalEventRouter with function handlersSimple mappings, composition
OO (Object-Oriented)Saga class with @prepares/@handles decoratorsRich translation logic, type safety
LanguageFunctionalOO
Python
C#
Rust
Java
Go
C++

EventRouter provides explicit registration of event handlers with typed callbacks.

@domain("table")
@output_domain("hand")
class TableHandSaga(Saga):
"""Saga that translates HandStarted events to DealCards commands.

Uses the OO pattern with @domain, @output_domain, @prepares, and @handles decorators.
"""

name = "saga-table-hand"

@prepares(table.HandStarted)
def prepare_hand_started(self, event: table.HandStarted) -> list[types.Cover]:
"""Declare the hand aggregate as destination."""
return [
types.Cover(
domain="hand",
root=types.UUID(value=event.hand_root),
)
]

@handles(table.HandStarted)
def handle_hand_started(
self,
event: table.HandStarted,
destinations: list[types.EventBook],
) -> types.CommandBook:
"""Translate HandStarted -> DealCards."""
# Get next sequence from destination state
dest_seq = next_sequence(destinations[0]) if destinations else 0

# Convert SeatSnapshot to PlayerInHand
players = [
hand.PlayerInHand(
player_root=seat.player_root,
position=seat.position,
stack=seat.stack,
)
for seat in event.active_players
]

# Build DealCards command
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 pre-packed CommandBook for full control
from google.protobuf.any_pb2 import Any

cmd_any = Any()
cmd_any.Pack(deal_cards, type_url_prefix="type.googleapis.com/")

return types.CommandBook(
cover=types.Cover(
domain="hand",
root=types.UUID(value=event.hand_root),
),
pages=[
types.CommandPage(
sequence=dest_seq,
command=cmd_any,
)
],
)



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_join_rejected(
notification: types.Notification,
state: PlayerState,
) -> types.EventBook | None:
"""Handle JoinTable rejection by releasing reserved funds.

Called when the JoinTable command (issued by saga-player-table after
FundsReserved) is rejected by the Table aggregate.
"""
from google.protobuf.any_pb2 import Any

# Extract rejection details from the notification payload
rejection = types.RejectionNotification()
if notification.payload:
notification.payload.Unpack(rejection)

# Extract table_root from the rejected command
table_root = b""
if rejection.rejected_command and rejection.rejected_command.cover:
if rejection.rejected_command.cover.root:
table_root = rejection.rejected_command.cover.root.value

# Release the funds that were reserved for this table
table_key = table_root.hex()
reserved_amount = state.table_reservations.get(table_key, 0)
new_reserved = state.reserved_funds - reserved_amount
new_available = state.bankroll - new_reserved

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

# Pack the event
event_any = Any()
event_any.Pack(event, type_url_prefix="type.googleapis.com/")

# Build the EventBook using the notification's cover for routing
return types.EventBook(
cover=notification.cover,
pages=[types.EventPage(header=types.PageHeader(sequence=0), event=event_any)],
)



Saga Principles

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

Running Sagas: EventRouter Registration

Functional sagas use explicit router registration. All 6 languages follow this pattern:

if __name__ == "__main__":
handler = SagaHandler(TableHandSaga)
run_saga_server("saga-table-hand", "50411", handler, logger=logger)

Next Steps