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 |
|---|---|---|
| OO (Object-Oriented) | Saga class with @prepares/@reacts_to decorators | Rich translation logic, type safety |
| Functional | EventRouter with function handlers | Simple mappings, composition |
| Language | OO | Functional |
|---|---|---|
| Python | ✓ | ✓ |
| Java | ✓ | ✓ |
| C# | — | ✓ |
| Rust | — | ✓ |
| Go | — | ✓ |
| C++ | — | ✓ |
- Python OO
- Java OO
- C#
- Go
- Rust
- C++
examples/python/table/saga-hand-oo/main.py
class TableHandSaga(Saga):
"""Saga that translates HandStarted events to DealCards commands.
Uses the OO pattern with @prepares and @reacts_to decorators.
"""
name = "saga-table-hand"
input_domain = "table"
output_domain = "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),
)
]
@reacts_to(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,
)
],
)
examples/java/table/saga-hand/src/main/java/dev/angzarr/examples/table/sagahand/TableHandSaga.java
/**
* 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()
);
}
@ReactsTo(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()
.setSequence(destSeq)
.setCommand(Any.pack(dealCards, "type.googleapis.com/")))
.build();
}
}
examples/csharp/Table/SagaHand/TableHandSaga.cs
C# uses the functional pattern with EventRouter:
private static object HandleHandStarted(HandStarted evt, List<EventBook> destinations)
{
var destSeq = EventRouter.NextSequence(destinations.FirstOrDefault());
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 { Sequence = destSeq, Command = cmdAny } }
};
}
examples/go/table/saga-hand/main.go
Go uses the functional pattern with EventRouter:
// handleHandStarted translates HandStarted → DealCards.
func handleHandStarted(source *pb.EventBook, event *anypb.Any, destinations []*pb.EventBook) ([]*pb.CommandBook, error) {
var handStarted examples.HandStarted
if err := proto.Unmarshal(event.Value, &handStarted); err != nil {
return nil, err
}
// Get next sequence from destination state
var destSeq uint32
if len(destinations) > 0 {
destSeq = angzarr.NextSequence(destinations[0])
}
// Get correlation ID from source
var correlationID string
if source.Cover != nil {
correlationID = source.Cover.CorrelationId
}
// Convert SeatSnapshot to PlayerInHand
players := make([]*examples.PlayerInHand, len(handStarted.ActivePlayers))
for i, seat := range handStarted.ActivePlayers {
players[i] = &examples.PlayerInHand{
PlayerRoot: seat.PlayerRoot,
Position: seat.Position,
Stack: seat.Stack,
}
}
// Build DealCards command
dealCards := &examples.DealCards{
TableRoot: handStarted.HandRoot,
HandNumber: handStarted.HandNumber,
GameVariant: handStarted.GameVariant,
Players: players,
DealerPosition: handStarted.DealerPosition,
SmallBlind: handStarted.SmallBlind,
BigBlind: handStarted.BigBlind,
}
cmdAny, err := anypb.New(dealCards)
if err != nil {
return nil, err
}
return []*pb.CommandBook{
{
Cover: &pb.Cover{
Domain: "hand",
Root: &pb.UUID{Value: handStarted.HandRoot},
CorrelationId: correlationID,
},
Pages: []*pb.CommandPage{
{
Sequence: destSeq,
Command: cmdAny,
},
},
},
}, nil
}
examples/rust/table/saga-hand/src/main.rs
Rust uses the functional pattern with EventRouter:
/// Execute handler: translate HandStarted → DealCards.
fn handle_hand_started(
_source: &EventBook,
event_any: &Any,
destinations: &[EventBook],
) -> CommandResult<Option<CommandBook>> {
let event: HandStarted = event_any
.unpack()
.map_err(|e| CommandRejectedError::new(format!("Failed to decode HandStarted: {}", e)))?;
// Get the destination's next sequence
let dest_seq = destinations
.first()
.map(|eb| eb.next_sequence)
.unwrap_or(0);
// 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(Some(CommandBook {
cover: Some(Cover {
domain: "hand".to_string(),
root: Some(Uuid { value: event.hand_root }),
..Default::default()
}),
pages: vec![CommandPage {
sequence: dest_seq,
payload: Some(command_page::Payload::Command(command_any)),
..Default::default()
}],
saga_origin: None,
}))
}
examples/cpp/table/saga-hand/src/table_hand_saga.cpp
C++ uses the functional pattern with EventRouter:
angzarr::CommandBook handle_hand_started(
const examples::HandStarted& event,
const std::vector<angzarr::EventBook>& destinations) {
int dest_seq = destinations.empty() ? 0 : destinations[0].next_sequence();
// 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());
auto* page = cmd_book.add_pages();
page->set_sequence(dest_seq);
page->mutable_command()->CopyFrom(cmd_any);
return 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
- Rust
- Go
- Java
- C#
examples/python/player/agg/handlers/player.py
@rejected(domain="table", command="JoinTable")
def handle_join_rejected(
self, notification: types.Notification
) -> player_proto.FundsReleased:
"""Release reserved funds when table join fails.
Called when the JoinTable command (issued by saga-player-table after
FundsReserved) is rejected by the Table aggregate.
"""
ctx = CompensationContext.from_notification(notification)
logger.warning(
"Player compensation for JoinTable rejection: reason=%s",
ctx.rejection_reason,
)
# Extract table_root from the rejected command
table_root = b""
if ctx.rejected_command and ctx.rejected_command.cover:
table_root = ctx.rejected_command.cover.root.value
# Release the funds that were reserved for this table
reserved_amount = self.table_reservations.get(table_root.hex(), 0)
new_reserved = self.reserved_funds - reserved_amount
new_available = self.bankroll - new_reserved
return player_proto.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(),
)
examples/rust/player/agg/src/handlers/rejected.rs
/// 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,
})
}
examples/go/player/agg/handlers/revocation.go
// 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)
return angzarr.EmitCompensationEvents(angzarr.NewEventBookFromNotification(notification, eventAny))
}
examples/java/player/agg/src/main/java/dev/angzarr/examples/player/Player.java
@Rejected(domain = "table", command = "JoinTable")
public FundsReleased handleJoinRejected(Notification notification) {
var ctx = CompensationContext.from(notification);
logger.warning("Player compensation for JoinTable rejection: reason=" + ctx.getRejectionReason());
// Extract table_root from the rejected command
byte[] tableRoot = new byte[0];
if (ctx.getRejectedCommand() != null
&& ctx.getRejectedCommand().getCover() != null
&& ctx.getRejectedCommand().getCover().hasRoot()) {
tableRoot = ctx.getRejectedCommand().getCover().getRoot().getValue().toByteArray();
}
// Release the funds that were reserved for this table
String tableKey = bytesToHex(tableRoot);
long reservedAmount = getState().getReservationForTable(tableKey);
long newReserved = getReservedFunds() - reservedAmount;
long newAvailable = getBankroll() - newReserved;
return FundsReleased.newBuilder()
.setAmount(Currency.newBuilder()
.setAmount(reservedAmount)
.setCurrencyCode("CHIPS"))
.setTableRoot(com.google.protobuf.ByteString.copyFrom(tableRoot))
.setNewAvailableBalance(Currency.newBuilder()
.setAmount(newAvailable)
.setCurrencyCode("CHIPS"))
.setNewReservedBalance(Currency.newBuilder()
.setAmount(newReserved)
.setCurrencyCode("CHIPS"))
.setReleasedAt(now())
.build();
}
examples/csharp/Player/Agg/Player.cs
[Rejected("table", "JoinTable")]
public FundsReleased HandleTableJoinRejected(Notification notification)
{
var ctx = CompensationContext.From(notification);
Console.WriteLine($"Player compensation for JoinTable rejection: reason={ctx.RejectionReason}");
// Extract table_root from the rejected command
var tableRoot = ctx.RejectedCommand?.Cover?.Root?.Value ?? ByteString.Empty;
// Release the funds that were reserved for this table
var tableKey = Convert.ToHexString(tableRoot.ToByteArray()).ToLowerInvariant();
TableReservations.TryGetValue(tableKey, out var reservedAmount);
var newReserved = ReservedFunds - reservedAmount;
var newAvailable = Bankroll - newReserved;
return new FundsReleased
{
Amount = new Currency { Amount = reservedAmount, CurrencyCode = "CHIPS" },
TableRoot = tableRoot,
NewAvailableBalance = new Currency { Amount = newAvailable, CurrencyCode = "CHIPS" },
NewReservedBalance = new Currency { Amount = newReserved, CurrencyCode = "CHIPS" },
ReleasedAt = Timestamp.FromDateTime(DateTime.UtcNow)
};
}
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
OO Pattern (Java/Python)
OO sagas use annotation-based routing. The framework discovers @Prepares and @ReactsTo methods via reflection:
- Java
- Python
// Main entry point
public static void main(String[] args) {
SagaHandler handler = new SagaHandler(new TableHandSaga());
SagaServer.run("saga-table-hand", "50411", handler);
}
if __name__ == "__main__":
handler = SagaHandler(TableHandSaga)
run_saga_server("saga-table-hand", "50411", handler)
Functional Pattern (C#/Go/Rust/C++)
Functional sagas use explicit router registration:
- C#
- Go
- Rust
- C++
public static EventRouter Create()
{
return new EventRouter("saga-table-hand")
.Domain("table")
.Prepare<HandStarted>(PrepareHandStarted)
.On<HandStarted>(HandleHandStarted);
}
router := angzarr.NewEventRouter("saga-table-hand").
Domain("table").
Prepare("HandStarted", prepareHandStarted).
On("HandStarted", handleHandStarted)
let router = EventRouter::new("saga-table-hand")
.domain("table")
.prepare("examples.HandStarted", prepare_hand_started)
.on("examples.HandStarted", handle_hand_started);
angzarr::EventRouter create_table_hand_router() {
return angzarr::EventRouter("saga-table-hand")
.domain("table")
.prepare<examples::HandStarted>(prepare_hand_started)
.on<examples::HandStarted>(handle_hand_started);
}
Next Steps
- Aggregates — Handler examples
- Process Managers — Multi-domain orchestration
- Testing — Saga testing with Gherkin