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:
| Style | Description | Best For |
|---|---|---|
| Functional | EventRouter with function handlers | Simple mappings, composition |
| OO (Object-Oriented) | Saga class with @prepares/@handles decorators | Rich translation logic, type safety |
| Language | Functional | OO |
|---|---|---|
| Python | ✓ | ✓ |
| C# | ✓ | ✓ |
| Rust | ✓ | ✓ |
| Java | ✓ | ✓ |
| Go | ✓ | ✓ |
| C++ | ✓ | ✓ |
- Functional
- OO
EventRouter provides explicit registration of event handlers with typed callbacks.
- Python
- C#
- Rust
- Java
- Go
- C++
@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,
)
],
)
private static object HandleHandStarted(HandStarted evt, List<EventBook> destinations)
{
// Sagas are stateless - destinations not used, framework stamps sequences
var players = evt
.ActivePlayers.Select(seat => new PlayerInHand
{
PlayerRoot = seat.PlayerRoot,
Position = seat.Position,
Stack = seat.Stack,
})
.ToList();
var dealCards = new DealCards
{
TableRoot = evt.HandRoot,
HandNumber = evt.HandNumber,
GameVariant = evt.GameVariant,
DealerPosition = evt.DealerPosition,
SmallBlind = evt.SmallBlind,
BigBlind = evt.BigBlind,
};
dealCards.Players.AddRange(players);
var cmdAny = EventRouter.PackCommand(dealCards);
return new CommandBook
{
Cover = new Cover
{
Domain = "hand",
Root = new UUID { Value = evt.HandRoot },
},
Pages =
{
new CommandPage
{
Header = new PageHeader { AngzarrDeferred = new AngzarrDeferredSequence() },
Command = cmdAny,
},
},
};
}
// docs:start:saga_oo
/// Saga handler for Table → Hand domain translation.
#[derive(Clone)]
struct TableHandSagaHandler;
impl SagaDomainHandler for TableHandSagaHandler {
fn event_types(&self) -> Vec<String> {
vec!["HandStarted".into()]
}
fn handle(&self, source: &EventBook, event: &Any) -> CommandResult<SagaHandlerResponse> {
if event.type_url.ends_with("HandStarted") {
return Self::handle_hand_started(source, event);
}
Ok(SagaHandlerResponse::default())
}
}
impl TableHandSagaHandler {
/// Translate HandStarted → DealCards.
///
/// Commands use deferred sequences - framework assigns on delivery.
fn handle_hand_started(
_source: &EventBook,
event_any: &Any,
) -> CommandResult<SagaHandlerResponse> {
let event: HandStarted = event_any.unpack().map_err(|e| {
CommandRejectedError::new(format!("Failed to decode HandStarted: {}", e))
})?;
// Convert SeatSnapshot to PlayerInHand
let players: Vec<PlayerInHand> = event
.active_players
.iter()
.map(|seat| PlayerInHand {
player_root: seat.player_root.clone(),
position: seat.position,
stack: seat.stack,
})
.collect();
// Build DealCards command
let deal_cards = DealCards {
table_root: event.hand_root.clone(), // The hand_root becomes the table_root reference
hand_number: event.hand_number,
game_variant: event.game_variant,
players,
dealer_position: event.dealer_position,
small_blind: event.small_blind,
big_blind: event.big_blind,
deck_seed: vec![], // Let the aggregate generate a random seed
};
let command_any = Any {
type_url: "type.googleapis.com/examples.DealCards".to_string(),
value: deal_cards.encode_to_vec(),
};
Ok(SagaHandlerResponse {
commands: vec![CommandBook {
cover: Some(Cover {
domain: "hand".to_string(),
root: Some(Uuid {
value: event.hand_root,
}),
..Default::default()
}),
// Framework will stamp angzarr_deferred with source info
// and assign sequence on delivery
pages: vec![CommandPage {
payload: Some(command_page::Payload::Command(command_any)),
..Default::default()
}],
}],
events: vec![],
})
}
}
// docs:end:saga_oo
public static CommandBook handleHandStarted(HandStarted event, List<EventBook> destinations) {
// Sagas are stateless - destinations not used, framework stamps sequences
List<PlayerInHand> players = new ArrayList<>();
for (SeatSnapshot seat : event.getActivePlayersList()) {
players.add(
PlayerInHand.newBuilder()
.setPlayerRoot(seat.getPlayerRoot())
.setPosition(seat.getPosition())
.setStack(seat.getStack())
.build());
}
DealCards dealCards =
DealCards.newBuilder()
.setTableRoot(event.getHandRoot())
.setHandNumber(event.getHandNumber())
.setGameVariant(event.getGameVariant())
.setDealerPosition(event.getDealerPosition())
.setSmallBlind(event.getSmallBlind())
.setBigBlind(event.getBigBlind())
.addAllPlayers(players)
.build();
return CommandBook.newBuilder()
.setCover(
Cover.newBuilder()
.setDomain("hand")
.setRoot(UUID.newBuilder().setValue(event.getHandRoot())))
.addPages(
CommandPage.newBuilder()
.setHeader(
PageHeader.newBuilder()
.setAngzarrDeferred(AngzarrDeferredSequence.newBuilder().build())
.build())
.setCommand(EventRouter.packCommand(dealCards)))
.build();
}
// NewTableHandSaga creates a new TableHandSaga with registered handlers.
func NewTableHandSaga() *TableHandSaga {
s := &TableHandSaga{}
s.Init("saga-table-hand", "table", "hand")
// Register event handler
s.Handles(s.handleHandStarted)
return s
}
// handleHandStarted translates HandStarted → DealCards.
// Sagas are stateless translators - framework handles sequence stamping.
func (s *TableHandSaga) handleHandStarted(
event *examples.HandStarted,
) (*pb.CommandBook, error) {
// Convert SeatSnapshot to PlayerInHand
players := make([]*examples.PlayerInHand, len(event.ActivePlayers))
for i, seat := range event.ActivePlayers {
players[i] = &examples.PlayerInHand{
PlayerRoot: seat.PlayerRoot,
Position: seat.Position,
Stack: seat.Stack,
}
}
// Build DealCards command
dealCards := &examples.DealCards{
TableRoot: event.HandRoot,
HandNumber: event.HandNumber,
GameVariant: event.GameVariant,
Players: players,
DealerPosition: event.DealerPosition,
SmallBlind: event.SmallBlind,
BigBlind: event.BigBlind,
}
cmdAny, err := anypb.New(dealCards)
if err != nil {
return nil, err
}
// Use angzarr_deferred - framework stamps sequence on delivery
return &pb.CommandBook{
Cover: &pb.Cover{
Domain: "hand",
Root: &pb.UUID{Value: event.HandRoot},
},
Pages: []*pb.CommandPage{
{
Header: &pb.PageHeader{SequenceType: &pb.PageHeader_AngzarrDeferred{AngzarrDeferred: &pb.AngzarrDeferredSequence{}}},
Payload: &pb.CommandPage_Command{Command: cmdAny},
},
},
}, nil
}
// Handle HandStarted: produce DealCards command for hand.
// Sagas are stateless translators - framework handles sequence stamping.
static std::vector<angzarr::CommandBook> handle_hand_started(
const google::protobuf::Any& event_any, const std::string& source_root,
const std::string& correlation_id, const std::vector<angzarr::EventBook>& destinations) {
(void)source_root;
(void)destinations; // Sagas are stateless - destinations not used
examples::HandStarted event;
event_any.UnpackTo(&event);
// Build DealCards command from HandStarted event
examples::DealCards deal_cards;
deal_cards.set_table_root(event.hand_root());
deal_cards.set_hand_number(event.hand_number());
deal_cards.set_game_variant(event.game_variant());
deal_cards.set_dealer_position(event.dealer_position());
deal_cards.set_small_blind(event.small_blind());
deal_cards.set_big_blind(event.big_blind());
// Add players from active players
for (const auto& seat : event.active_players()) {
auto* player = deal_cards.add_players();
player->set_player_root(seat.player_root());
player->set_position(seat.position());
player->set_stack(seat.stack());
}
// Pack command
google::protobuf::Any cmd_any;
cmd_any.PackFrom(deal_cards, "type.googleapis.com/");
// Build command book
angzarr::CommandBook cmd_book;
cmd_book.mutable_cover()->set_domain("hand");
cmd_book.mutable_cover()->mutable_root()->set_value(event.hand_root());
cmd_book.mutable_cover()->set_correlation_id(correlation_id);
auto* page = cmd_book.add_pages();
// Framework handles sequence stamping
page->mutable_header()->mutable_angzarr_deferred();
page->mutable_command()->CopyFrom(cmd_any);
return {std::move(cmd_book)};
}
OO sagas use @prepares/@handles decorators on class methods.
- Python
- C#
- Rust
- Java
- Go
- C++
# docs:start:saga_handler
@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,
)
],
)
# docs:end:saga_handler
/// <summary>
/// Saga: Table -> Hand (OO Pattern)
///
/// Reacts to HandStarted events from Table domain.
/// Sends DealCards commands to Hand domain.
/// Sagas are stateless translators - framework handles sequence stamping.
///
/// Uses annotation-based handler registration with:
/// - [Handles(typeof(EventType))] for handle phase handlers
/// </summary>
public class TableHandSaga : Saga
{
public override string Name => "saga-table-hand";
public override string InputDomain => "table";
public override string OutputDomain => "hand";
/// <summary>
/// Handle phase: translate Table.HandStarted -> Hand.DealCards.
///
/// Called with the source event. Framework handles sequence stamping.
/// </summary>
[Handles(typeof(HandStarted))]
public CommandBook HandleHandStarted(HandStarted evt, List<EventBook> destinations)
{
// Sagas are stateless - destinations not used, framework stamps sequences
// Convert SeatSnapshot to PlayerInHand
var players = evt
.ActivePlayers.Select(seat => new PlayerInHand
{
PlayerRoot = seat.PlayerRoot,
Position = seat.Position,
Stack = seat.Stack,
})
.ToList();
// Build DealCards command
var dealCards = new DealCards
{
TableRoot = evt.HandRoot,
HandNumber = evt.HandNumber,
GameVariant = evt.GameVariant,
DealerPosition = evt.DealerPosition,
SmallBlind = evt.SmallBlind,
BigBlind = evt.BigBlind,
};
dealCards.Players.AddRange(players);
return new CommandBook
{
Cover = new Cover
{
Domain = "hand",
Root = new UUID { Value = evt.HandRoot },
},
Pages =
{
new CommandPage
{
Header = new PageHeader { AngzarrDeferred = new AngzarrDeferredSequence() },
Command = PackCommand(dealCards),
},
},
};
}
}
/// Saga handler for Table → Hand domain translation.
#[derive(Clone)]
struct TableHandSagaHandler;
impl SagaDomainHandler for TableHandSagaHandler {
fn event_types(&self) -> Vec<String> {
vec!["HandStarted".into()]
}
fn handle(&self, source: &EventBook, event: &Any) -> CommandResult<SagaHandlerResponse> {
if event.type_url.ends_with("HandStarted") {
return Self::handle_hand_started(source, event);
}
Ok(SagaHandlerResponse::default())
}
}
impl TableHandSagaHandler {
/// Translate HandStarted → DealCards.
///
/// Commands use deferred sequences - framework assigns on delivery.
fn handle_hand_started(
_source: &EventBook,
event_any: &Any,
) -> CommandResult<SagaHandlerResponse> {
let event: HandStarted = event_any.unpack().map_err(|e| {
CommandRejectedError::new(format!("Failed to decode HandStarted: {}", e))
})?;
// Convert SeatSnapshot to PlayerInHand
let players: Vec<PlayerInHand> = event
.active_players
.iter()
.map(|seat| PlayerInHand {
player_root: seat.player_root.clone(),
position: seat.position,
stack: seat.stack,
})
.collect();
// Build DealCards command
let deal_cards = DealCards {
table_root: event.hand_root.clone(), // The hand_root becomes the table_root reference
hand_number: event.hand_number,
game_variant: event.game_variant,
players,
dealer_position: event.dealer_position,
small_blind: event.small_blind,
big_blind: event.big_blind,
deck_seed: vec![], // Let the aggregate generate a random seed
};
let command_any = Any {
type_url: "type.googleapis.com/examples.DealCards".to_string(),
value: deal_cards.encode_to_vec(),
};
Ok(SagaHandlerResponse {
commands: vec![CommandBook {
cover: Some(Cover {
domain: "hand".to_string(),
root: Some(Uuid {
value: event.hand_root,
}),
..Default::default()
}),
// Framework will stamp angzarr_deferred with source info
// and assign sequence on delivery
pages: vec![CommandPage {
payload: Some(command_page::Payload::Command(command_any)),
..Default::default()
}],
}],
events: vec![],
})
}
}
/**
* Saga: Table -> Hand (OO Pattern)
*
* <p>Reacts to HandStarted events from Table domain. Sends DealCards commands to Hand domain.
*/
public class TableHandSaga extends Saga {
public TableHandSaga() {
super("saga-table-hand", "table", "hand");
}
@Prepares(HandStarted.class)
public List<Cover> prepareHandStarted(HandStarted event) {
return List.of(
Cover.newBuilder()
.setDomain("hand")
.setRoot(UUID.newBuilder().setValue(event.getHandRoot()))
.build());
}
@Handles(HandStarted.class)
public CommandBook handleHandStarted(HandStarted event, List<EventBook> destinations) {
int destSeq = Saga.nextSequence(destinations.isEmpty() ? null : destinations.get(0));
// Convert SeatSnapshot to PlayerInHand
List<PlayerInHand> players = new ArrayList<>();
for (SeatSnapshot seat : event.getActivePlayersList()) {
players.add(
PlayerInHand.newBuilder()
.setPlayerRoot(seat.getPlayerRoot())
.setPosition(seat.getPosition())
.setStack(seat.getStack())
.build());
}
// Build DealCards command
DealCards dealCards =
DealCards.newBuilder()
.setTableRoot(event.getHandRoot())
.setHandNumber(event.getHandNumber())
.setGameVariant(event.getGameVariant())
.setDealerPosition(event.getDealerPosition())
.setSmallBlind(event.getSmallBlind())
.setBigBlind(event.getBigBlind())
.addAllPlayers(players)
.build();
return CommandBook.newBuilder()
.setCover(
Cover.newBuilder()
.setDomain("hand")
.setRoot(UUID.newBuilder().setValue(event.getHandRoot())))
.addPages(
CommandPage.newBuilder()
.setHeader(PageHeader.newBuilder().setSequence(destSeq).build())
.setCommand(Any.pack(dealCards, "type.googleapis.com/")))
.build();
}
}
// docs:start:saga_handler
// NewTableHandSaga creates a new TableHandSaga with registered handlers.
func NewTableHandSaga() *TableHandSaga {
s := &TableHandSaga{}
s.Init("saga-table-hand", "table", "hand")
// Register event handler
s.Handles(s.handleHandStarted)
return s
}
// handleHandStarted translates HandStarted → DealCards.
// Sagas are stateless translators - framework handles sequence stamping.
func (s *TableHandSaga) handleHandStarted(
event *examples.HandStarted,
) (*pb.CommandBook, error) {
// Convert SeatSnapshot to PlayerInHand
players := make([]*examples.PlayerInHand, len(event.ActivePlayers))
for i, seat := range event.ActivePlayers {
players[i] = &examples.PlayerInHand{
PlayerRoot: seat.PlayerRoot,
Position: seat.Position,
Stack: seat.Stack,
}
}
// Build DealCards command
dealCards := &examples.DealCards{
TableRoot: event.HandRoot,
HandNumber: event.HandNumber,
GameVariant: event.GameVariant,
Players: players,
DealerPosition: event.DealerPosition,
SmallBlind: event.SmallBlind,
BigBlind: event.BigBlind,
}
cmdAny, err := anypb.New(dealCards)
if err != nil {
return nil, err
}
// Use angzarr_deferred - framework stamps sequence on delivery
return &pb.CommandBook{
Cover: &pb.Cover{
Domain: "hand",
Root: &pb.UUID{Value: event.HandRoot},
},
Pages: []*pb.CommandPage{
{
Header: &pb.PageHeader{SequenceType: &pb.PageHeader_AngzarrDeferred{AngzarrDeferred: &pb.AngzarrDeferredSequence{}}},
Payload: &pb.CommandPage_Command{Command: cmdAny},
},
},
}, nil
}
// docs:end:saga_handler
// Handle HandStarted: produce DealCards command for hand.
// Sagas are stateless translators - framework handles sequence stamping.
static std::vector<angzarr::CommandBook> handle_hand_started(
const google::protobuf::Any& event_any, const std::string& source_root,
const std::string& correlation_id, const std::vector<angzarr::EventBook>& destinations) {
(void)source_root;
(void)destinations; // Sagas are stateless - destinations not used
examples::HandStarted event;
event_any.UnpackTo(&event);
// Build DealCards command from HandStarted event
examples::DealCards deal_cards;
deal_cards.set_table_root(event.hand_root());
deal_cards.set_hand_number(event.hand_number());
deal_cards.set_game_variant(event.game_variant());
deal_cards.set_dealer_position(event.dealer_position());
deal_cards.set_small_blind(event.small_blind());
deal_cards.set_big_blind(event.big_blind());
// Add players from active players
for (const auto& seat : event.active_players()) {
auto* player = deal_cards.add_players();
player->set_player_root(seat.player_root());
player->set_position(seat.position());
player->set_stack(seat.stack());
}
// Pack command
google::protobuf::Any cmd_any;
cmd_any.PackFrom(deal_cards, "type.googleapis.com/");
// Build command book
angzarr::CommandBook cmd_book;
cmd_book.mutable_cover()->set_domain("hand");
cmd_book.mutable_cover()->mutable_root()->set_value(event.hand_root());
cmd_book.mutable_cover()->set_correlation_id(correlation_id);
auto* page = cmd_book.add_pages();
// Framework handles sequence stamping
page->mutable_header()->mutable_angzarr_deferred();
page->mutable_command()->CopyFrom(cmd_any);
return {std::move(cmd_book)};
}
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.
- Python
- C#
- Rust
- Java
- Go
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)],
)
illustrative - rejection handler
[RejectionHandler("JoinTable")]
public FundsReleased HandleJoinRejected(RejectionNotification rejection)
{
var tableRoot = rejection.RejectedCommand.Cover.Root.Value;
var reserved = State.TableReservations[tableRoot];
return new FundsReleased { Amount = reserved, TableRoot = tableRoot };
}
/// 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.
pub fn handle_join_rejected(
notification: &Notification,
state: &PlayerState,
) -> CommandResult<RejectionHandlerResponse> {
// Extract rejection details from the notification payload
let rejection = notification
.payload
.as_ref()
.and_then(|any| any.unpack::<RejectionNotification>().ok())
.unwrap_or_default();
warn!(
rejection_reason = %rejection.rejection_reason,
"Player compensation for JoinTable rejection"
);
// Extract table_root from the rejected command
let table_root = rejection
.rejected_command
.as_ref()
.and_then(|cmd| cmd.cover.as_ref())
.map(|cover| {
cover
.root
.as_ref()
.map(|r| r.value.clone())
.unwrap_or_default()
})
.unwrap_or_default();
// Release the funds that were reserved for this table
let table_key = hex::encode(&table_root);
let reserved_amount = state
.table_reservations
.get(&table_key)
.copied()
.unwrap_or(0);
let new_reserved = state.reserved_funds - reserved_amount;
let new_available = state.bankroll - new_reserved;
let event = FundsReleased {
amount: Some(Currency {
amount: reserved_amount,
currency_code: "CHIPS".to_string(),
}),
table_root,
new_available_balance: Some(Currency {
amount: new_available,
currency_code: "CHIPS".to_string(),
}),
new_reserved_balance: Some(Currency {
amount: new_reserved,
currency_code: "CHIPS".to_string(),
}),
released_at: Some(now()),
};
let event_any = pack_event(&event, "examples.FundsReleased");
// Build the EventBook using the notification's cover for routing.
// Sequence 0 is a placeholder - framework assigns actual sequence during persist.
let event_book = EventBook {
cover: notification.cover.clone(),
pages: vec![event_page(0, event_any)],
snapshot: None,
next_sequence: 0,
};
Ok(RejectionHandlerResponse {
events: Some(event_book),
notification: None,
})
}
illustrative - rejection handler
@RejectionHandler("JoinTable")
public FundsReleased handleJoinRejected(RejectionNotification rejection) {
byte[] tableRoot = rejection.getRejectedCommand().getCover().getRoot().getValue();
long reserved = state.getTableReservations().get(tableRoot);
return FundsReleased.newBuilder().setAmount(reserved).setTableRoot(tableRoot).build();
}
// HandleTableJoinRejected handles compensation when a table join fails.
//
// Called when a saga/PM command targeting the table aggregate's JoinTable
// command is rejected. This releases the funds that were reserved for the
// failed table join.
func HandleTableJoinRejected(notification *pb.Notification, state PlayerState) *pb.BusinessResponse {
ctx := angzarr.NewCompensationContext(notification)
log.Printf("Player compensation for JoinTable rejection: reason=%s",
ctx.RejectionReason)
// Extract table_root from the rejected command
var tableRoot []byte
if ctx.RejectedCommand != nil && ctx.RejectedCommand.Cover != nil && ctx.RejectedCommand.Cover.Root != nil {
tableRoot = ctx.RejectedCommand.Cover.Root.Value
}
// Release the funds that were reserved for this table
tableKey := hex.EncodeToString(tableRoot)
reservedAmount := state.TableReservations[tableKey]
newReserved := state.ReservedFunds - reservedAmount
newAvailable := state.Bankroll - newReserved
event := &examples.FundsReleased{
Amount: &examples.Currency{Amount: reservedAmount, CurrencyCode: "CHIPS"},
TableRoot: tableRoot,
NewAvailableBalance: &examples.Currency{Amount: newAvailable, CurrencyCode: "CHIPS"},
NewReservedBalance: &examples.Currency{Amount: newReserved, CurrencyCode: "CHIPS"},
ReleasedAt: timestamppb.Now(),
}
eventAny, _ := anypb.New(event)
eventBook := &pb.EventBook{
Cover: notification.Cover,
Pages: []*pb.EventPage{
{
Payload: &pb.EventPage_Event{Event: eventAny},
},
},
}
return angzarr.EmitCompensationEvents(eventBook)
}
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()
Running Sagas: EventRouter Registration
Functional sagas use explicit router registration. All 6 languages follow this pattern:
- Python
- C#
- Rust
- Java
- Go
- C++
if __name__ == "__main__":
handler = SagaHandler(TableHandSaga)
run_saga_server("saga-table-hand", "50411", handler, logger=logger)
public static EventRouter Create()
{
return new EventRouter("saga-table-hand")
.Domain("table")
.On<HandStarted>(HandleHandStarted);
}
let router = SagaRouter::new("saga-table-hand", "table", TableHandSagaHandler);
public static EventRouter createRouter() {
return new EventRouter("saga-table-hand")
.domain("table")
.on(HandStarted.class, TableHandRouter::handleHandStarted);
}
func main() {
saga := NewTableHandSaga()
angzarr.RunOOSagaServer("saga-table-hand", "50211", saga)
}
angzarr::EventRouter create_table_hand_router() {
return angzarr::EventRouter("saga-table-hand")
.domain("table")
.on("HandStarted", handle_hand_started);
}
Next Steps
- Aggregates — Handler examples
- Process Managers — Multi-domain orchestration
- Testing — Saga testing with Gherkin