Aggregate Examples
Real handler examples from the poker domain. All code is from the actual examples/ directory.
⍼ Angzarr supports two implementation styles:
| Style | Description | Best For |
|---|---|---|
| Functional | Pure guard()/validate()/compute() functions | Simple handlers, easy testing |
| OO (Object-Oriented) | Aggregate class with @handles/[Handles] decorators | Rich domain models, encapsulation |
| Language | Functional | OO |
|---|---|---|
| Python | ✓ | ✓ |
| C# | ✓ | ✓ |
| Rust | ✓ | ✓ |
| Java | ✓ | ✓ |
| Go | ✓ | ✓ |
| C++ | ✓ | ✓ |
Command Handlers
The examples below show the guard → validate → compute pattern (functional) and decorator-based dispatch (OO).
- Functional
- OO
Pure functions following guard → validate → compute. Each function has a single responsibility, directly unit testable without mocks.
- Python
- C#
- Rust
- Java
- Go
- C++
def deposit_guard(state: PlayerState) -> None:
"""Check state preconditions before processing deposit."""
if not state.exists:
raise CommandRejectedError("Player does not exist")
# docs:end:deposit_guard
# docs:start:deposit_validate
def deposit_validate(cmd: player.DepositFunds) -> int:
"""Validate deposit command and extract amount."""
amount = cmd.amount.amount if cmd.amount else 0
if amount <= 0:
raise CommandRejectedError("amount must be positive")
return amount
# docs:end:deposit_validate
# docs:start:deposit_compute
def deposit_compute(
cmd: player.DepositFunds, state: PlayerState, amount: int
) -> player.FundsDeposited:
"""Build FundsDeposited event from validated inputs."""
new_balance = state.bankroll + amount
return player.FundsDeposited(
amount=cmd.amount,
new_balance=poker_types.Currency(amount=new_balance, currency_code="CHIPS"),
deposited_at=now(),
)
internal static void Guard(PlayerState state)
{
if (!state.Exists)
throw CommandRejectedError.PreconditionFailed("Player does not exist");
}
// docs:end:deposit_guard
// docs:start:deposit_validate
internal static long Validate(DepositFunds cmd)
{
var amount = cmd.Amount?.Amount ?? 0;
if (amount <= 0)
throw CommandRejectedError.InvalidArgument("amount must be positive");
return amount;
}
// docs:end:deposit_validate
// docs:start:deposit_compute
internal static FundsDeposited Compute(DepositFunds cmd, PlayerState state, long amount)
{
var newBalance = state.Bankroll + amount;
return new FundsDeposited
{
Amount = cmd.Amount,
NewBalance = new Currency { Amount = newBalance, CurrencyCode = "CHIPS" },
DepositedAt = Timestamp.FromDateTime(DateTime.UtcNow),
};
}
fn guard(state: &PlayerState) -> CommandResult<()> {
if !state.exists() {
return Err(CommandRejectedError::new("Player does not exist"));
}
Ok(())
}
// docs:end:deposit_guard
// docs:start:deposit_validate
fn validate(cmd: &DepositFunds) -> CommandResult<i64> {
let amount = cmd.amount.as_ref().map(|c| c.amount).unwrap_or(0);
if amount <= 0 {
return Err(CommandRejectedError::new("amount must be positive"));
}
Ok(amount)
}
// docs:end:deposit_validate
// docs:start:deposit_compute
fn compute(cmd: &DepositFunds, state: &PlayerState, amount: i64) -> FundsDeposited {
let new_balance = state.bankroll + amount;
FundsDeposited {
amount: cmd.amount.clone(),
new_balance: Some(Currency {
amount: new_balance,
currency_code: "CHIPS".to_string(),
}),
deposited_at: Some(angzarr_client::now()),
}
}
static void guard(PlayerState state) {
if (!state.exists()) {
throw Errors.CommandRejectedError.preconditionFailed("Player does not exist");
}
}
// docs:end:deposit_guard
// docs:start:deposit_validate
static long validate(DepositFunds cmd) {
long amount = cmd.hasAmount() ? cmd.getAmount().getAmount() : 0;
if (amount <= 0) {
throw Errors.CommandRejectedError.invalidArgument("amount must be positive");
}
return amount;
}
// docs:end:deposit_validate
// docs:start:deposit_compute
static FundsDeposited compute(DepositFunds cmd, PlayerState state, long amount) {
long newBalance = state.getBankroll() + amount;
return FundsDeposited.newBuilder()
.setAmount(cmd.getAmount())
.setNewBalance(Currency.newBuilder().setAmount(newBalance).setCurrencyCode("CHIPS"))
.setDepositedAt(now())
.build();
}
func guardDepositFunds(state PlayerState) error {
if !state.Exists() {
return angzarr.NewCommandRejectedError("Player does not exist")
}
return nil
}
// docs:end:deposit_guard
// docs:start:deposit_validate
func validateDepositFunds(cmd *examples.DepositFunds) (int64, error) {
amount := int64(0)
if cmd.Amount != nil {
amount = cmd.Amount.Amount
}
if amount <= 0 {
return 0, angzarr.NewCommandRejectedError("amount must be positive")
}
return amount, nil
}
// docs:end:deposit_validate
// docs:start:deposit_compute
func computeFundsDeposited(cmd *examples.DepositFunds, state PlayerState, amount int64) *examples.FundsDeposited {
newBalance := state.Bankroll + amount
return &examples.FundsDeposited{
Amount: cmd.Amount,
NewBalance: &examples.Currency{Amount: newBalance, CurrencyCode: "CHIPS"},
DepositedAt: timestamppb.New(time.Now()),
}
}
void guard(const PlayerState& state) {
if (!state.exists()) {
throw angzarr::CommandRejectedError::precondition_failed("Player does not exist");
}
}
// docs:end:deposit_guard
// docs:start:deposit_validate
int64_t validate(const examples::DepositFunds& cmd) {
int64_t amount = cmd.has_amount() ? cmd.amount().amount() : 0;
if (amount <= 0) {
throw angzarr::CommandRejectedError::invalid_argument("amount must be positive");
}
return amount;
}
// docs:end:deposit_validate
// docs:start:deposit_compute
examples::FundsDeposited compute(const examples::DepositFunds& cmd, const PlayerState& state,
int64_t amount) {
int64_t new_balance = state.bankroll + amount;
examples::FundsDeposited event;
event.mutable_amount()->CopyFrom(cmd.amount());
event.mutable_new_balance()->set_amount(new_balance);
event.mutable_new_balance()->set_currency_code("CHIPS");
auto now = std::chrono::system_clock::now();
auto seconds = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
event.mutable_deposited_at()->set_seconds(seconds);
return event;
}
Class-based handlers with decorator/annotation registration. State managed by base class.
- Python
- C#
- Rust
- Java
- Go
- C++
@handles(table_proto.CreateTable)
def create(self, cmd: table_proto.CreateTable) -> table_proto.TableCreated:
"""Create a new table."""
if self.exists:
raise CommandRejectedError("Table already exists")
if not cmd.table_name:
raise CommandRejectedError("table_name is required")
if cmd.small_blind <= 0:
raise CommandRejectedError("small_blind must be positive")
if cmd.big_blind <= 0:
raise CommandRejectedError("big_blind must be positive")
if cmd.big_blind < cmd.small_blind:
raise CommandRejectedError("big_blind must be >= small_blind")
if cmd.max_players < 2 or cmd.max_players > 10:
raise CommandRejectedError("max_players must be between 2 and 10")
return table_proto.TableCreated(
table_name=cmd.table_name,
game_variant=cmd.game_variant,
small_blind=cmd.small_blind,
big_blind=cmd.big_blind,
min_buy_in=cmd.min_buy_in or cmd.big_blind * 20,
max_buy_in=cmd.max_buy_in or cmd.big_blind * 100,
max_players=cmd.max_players or 9,
action_timeout_seconds=cmd.action_timeout_seconds or 30,
created_at=now(),
)
[CommandHandler]
public FundsDeposited HandleDeposit(DepositFunds cmd) =>
new FundsDeposited { Amount = cmd.Amount, NewBalance = State.Bankroll + cmd.Amount };
#[aggregate(name = "table", domain = "table", state = TableState)]
impl TableAggregate {
#[handles(CreateTable)]
fn handle_create(
&self,
_cmd: &CommandBook,
create: &CreateTable,
state: &TableState,
seq: u32,
) -> Result<EventBook, String> {
// Guard: table must not exist
if !state.table_name.is_empty() {
return Err("Table already exists".into());
}
// Validate: required fields
if create.table_name.is_empty() {
return Err("table_name is required".into());
}
if create.small_blind == 0 {
return Err("small_blind must be positive".into());
}
// Compute: create the event
Ok(EventBook::single_event(
"table",
"TableCreated",
seq,
TableCreated {
table_name: create.table_name.clone(),
small_blind: create.small_blind,
big_blind: create.big_blind,
max_players: create.max_players,
..Default::default()
}
.encode_to_vec(),
))
}
#[applies(TableCreated)]
fn apply_created(state: &mut TableState, event: &TableCreated) {
state.table_name = event.table_name.clone();
state.small_blind = event.small_blind;
state.big_blind = event.big_blind;
state.max_players = event.max_players;
}
}
@CommandHandler
public FundsDeposited handleDeposit(DepositFunds cmd) {
return new FundsDeposited(cmd.getAmount(), state.getBankroll() + cmd.getAmount());
}
// NewHand creates a new Hand command handler with prior events for state reconstruction.
func NewHand(eventBook *pb.EventBook) *Hand {
h := &Hand{}
h.Init(eventBook, func() HandState {
return HandState{
Players: make(map[string]*PlayerHandState),
Pots: []*PotState{{PotType: "main"}},
}
})
h.SetDomain("hand")
// Register event appliers
h.Applies(h.applyCardsDealt)
h.Applies(h.applyBlindPosted)
h.Applies(h.applyActionTaken)
h.Applies(h.applyBettingRoundComplete)
h.Applies(h.applyCommunityCardsDealt)
h.Applies(h.applyDrawCompleted)
h.Applies(h.applyShowdownStarted)
h.Applies(h.applyCardsRevealed)
h.Applies(h.applyCardsMucked)
h.Applies(h.applyPotAwarded)
h.Applies(h.applyHandComplete)
// Register command handlers
h.Handles(h.dealCards)
h.Handles(h.postBlind)
h.Handles(h.playerAction)
h.Handles(h.dealCommunityCards)
h.Handles(h.requestDraw)
h.Handles(h.revealCards)
h.HandlesMulti(h.awardPot)
return h
}
Player::Player() {
// Register command handlers
register_handler<examples::RegisterPlayer, examples::PlayerRegistered>(
[this](const examples::RegisterPlayer& cmd, const PlayerState&) {
return handle_register(cmd);
});
register_handler<examples::DepositFunds, examples::FundsDeposited>(
[this](const examples::DepositFunds& cmd, const PlayerState&) {
return handle_deposit(cmd);
});
register_handler<examples::WithdrawFunds, examples::FundsWithdrawn>(
[this](const examples::WithdrawFunds& cmd, const PlayerState&) {
return handle_withdraw(cmd);
});
register_handler<examples::ReserveFunds, examples::FundsReserved>(
[this](const examples::ReserveFunds& cmd, const PlayerState&) {
return handle_reserve(cmd);
});
register_handler<examples::ReleaseFunds, examples::FundsReleased>(
[this](const examples::ReleaseFunds& cmd, const PlayerState&) {
return handle_release(cmd);
});
register_handler<examples::TransferFunds, examples::FundsTransferred>(
[this](const examples::TransferFunds& cmd, const PlayerState&) {
return handle_transfer(cmd);
});
}
examples::PlayerRegistered Player::handle_register(const examples::RegisterPlayer& cmd) {
return handlers::handle_register(cmd, state_);
}
examples::FundsDeposited Player::handle_deposit(const examples::DepositFunds& cmd) {
return handlers::handle_deposit(cmd, state_);
}
examples::FundsWithdrawn Player::handle_withdraw(const examples::WithdrawFunds& cmd) {
return handlers::handle_withdraw(cmd, state_);
}
examples::FundsReserved Player::handle_reserve(const examples::ReserveFunds& cmd) {
return handlers::handle_reserve(cmd, state_);
}
examples::FundsReleased Player::handle_release(const examples::ReleaseFunds& cmd) {
return handlers::handle_release(cmd, state_);
}
examples::FundsTransferred Player::handle_transfer(const examples::TransferFunds& cmd) {
return handlers::handle_transfer(cmd, state_);
}
Two-Phase Reservation Pattern
The Player aggregate demonstrates two-phase reservation. Funds are reserved before joining a table, then released if the join fails.
- Functional
- OO
- Python
- C#
- Rust
- Java
- Go
- C++
@command_handler(player.ReserveFunds)
def handle_reserve(
cmd: player.ReserveFunds, state: PlayerState, seq: int
) -> player.FundsReserved:
"""Reserve funds for a table buy-in."""
if not state.exists:
raise CommandRejectedError("Player does not exist")
amount = cmd.amount.amount if cmd.amount else 0
if amount <= 0:
raise CommandRejectedError("amount must be positive")
table_key = cmd.table_root.hex()
if table_key in state.table_reservations:
raise CommandRejectedError("Funds already reserved for this table")
if amount > state.available_balance:
raise CommandRejectedError("Insufficient funds")
new_reserved = state.reserved_funds + amount
new_available = state.bankroll - new_reserved
return player.FundsReserved(
amount=cmd.amount,
table_root=cmd.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"
),
reserved_at=now(),
)
/// <summary>
/// Handler for ReserveFunds command.
/// </summary>
public static class ReserveHandler
{
public static FundsReserved Handle(ReserveFunds cmd, PlayerState state)
{
// Guard
if (!state.Exists)
throw CommandRejectedError.PreconditionFailed("Player does not exist");
// Validate
var amount = cmd.Amount?.Amount ?? 0;
if (amount <= 0)
throw CommandRejectedError.InvalidArgument("amount must be positive");
var tableKey = Convert.ToHexString(cmd.TableRoot.ToByteArray()).ToLowerInvariant();
if (state.TableReservations.ContainsKey(tableKey))
throw CommandRejectedError.PreconditionFailed("Funds already reserved for this table");
if (amount > state.AvailableBalance)
throw CommandRejectedError.PreconditionFailed("Insufficient funds");
// Compute
var newReserved = state.ReservedFunds + amount;
var newAvailable = state.Bankroll - newReserved;
return new FundsReserved
{
Amount = cmd.Amount,
TableRoot = cmd.TableRoot,
NewAvailableBalance = new Currency { Amount = newAvailable, CurrencyCode = "CHIPS" },
NewReservedBalance = new Currency { Amount = newReserved, CurrencyCode = "CHIPS" },
ReservedAt = Timestamp.FromDateTime(DateTime.UtcNow),
};
}
}
fn guard(state: &PlayerState) -> CommandResult<()> {
if !state.exists() {
return Err(CommandRejectedError::new("Player does not exist"));
}
Ok(())
}
fn validate(cmd: &ReserveFunds, state: &PlayerState) -> CommandResult<i64> {
let amount = cmd.amount.as_ref().map(|c| c.amount).unwrap_or(0);
if amount <= 0 {
return Err(CommandRejectedError::new("amount must be positive"));
}
if amount > state.available_balance() {
return Err(CommandRejectedError::new("Insufficient funds"));
}
let table_key = hex::encode(&cmd.table_root);
if state.table_reservations.contains_key(&table_key) {
return Err(CommandRejectedError::new(
"Funds already reserved for this table",
));
}
Ok(amount)
}
fn compute(cmd: &ReserveFunds, state: &PlayerState, amount: i64) -> FundsReserved {
let new_reserved = state.reserved_funds + amount;
let new_available = state.bankroll - new_reserved;
FundsReserved {
amount: cmd.amount.clone(),
table_root: cmd.table_root.clone(),
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(),
}),
reserved_at: Some(angzarr_client::now()),
}
}
pub fn handle_reserve_funds(
command_book: &CommandBook,
command_any: &Any,
state: &PlayerState,
seq: u32,
) -> CommandResult<EventBook> {
let cmd: ReserveFunds = command_any
.unpack()
.map_err(|e| CommandRejectedError::new(format!("Failed to decode command: {}", e)))?;
guard(state)?;
let amount = validate(&cmd, state)?;
let event = compute(&cmd, state, amount);
let event_any = pack_event(&event, "examples.FundsReserved");
Ok(new_event_book(command_book, seq, event_any))
}
/** Functional handler for ReserveFunds command. */
public final class ReserveHandler {
private ReserveHandler() {}
public static FundsReserved handle(ReserveFunds cmd, PlayerState state) {
// Guard
if (!state.exists()) {
throw Errors.CommandRejectedError.preconditionFailed("Player does not exist");
}
// Validate
long amount = cmd.hasAmount() ? cmd.getAmount().getAmount() : 0;
if (amount <= 0) {
throw Errors.CommandRejectedError.invalidArgument("amount must be positive");
}
String tableKey = bytesToHex(cmd.getTableRoot().toByteArray());
if (state.hasReservationFor(tableKey)) {
throw Errors.CommandRejectedError.preconditionFailed("Funds already reserved for this table");
}
if (amount > state.getAvailableBalance()) {
throw Errors.CommandRejectedError.preconditionFailed("Insufficient funds");
}
// Compute
long newReserved = state.getReservedFunds() + amount;
long newAvailable = state.getBankroll() - newReserved;
return FundsReserved.newBuilder()
.setAmount(cmd.getAmount())
.setTableRoot(cmd.getTableRoot())
.setNewAvailableBalance(
Currency.newBuilder().setAmount(newAvailable).setCurrencyCode("CHIPS"))
.setNewReservedBalance(
Currency.newBuilder().setAmount(newReserved).setCurrencyCode("CHIPS"))
.setReservedAt(now())
.build();
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private static Timestamp now() {
Instant instant = Instant.now();
return Timestamp.newBuilder()
.setSeconds(instant.getEpochSecond())
.setNanos(instant.getNano())
.build();
}
}
func guardReserveFunds(state PlayerState) error {
if !state.Exists() {
return angzarr.NewCommandRejectedError("Player does not exist")
}
return nil
}
func validateReserveFunds(cmd *examples.ReserveFunds, state PlayerState) (int64, error) {
amount := int64(0)
if cmd.Amount != nil {
amount = cmd.Amount.Amount
}
if amount <= 0 {
return 0, angzarr.NewCommandRejectedError("amount must be positive")
}
if amount > state.AvailableBalance() {
return 0, angzarr.NewCommandRejectedError("Insufficient funds")
}
tableKey := hex.EncodeToString(cmd.TableRoot)
if _, exists := state.TableReservations[tableKey]; exists {
return 0, angzarr.NewCommandRejectedError("Funds already reserved for this table")
}
return amount, nil
}
func computeFundsReserved(cmd *examples.ReserveFunds, state PlayerState, amount int64) *examples.FundsReserved {
newReserved := state.ReservedFunds + amount
newAvailable := state.Bankroll - newReserved
return &examples.FundsReserved{
Amount: cmd.Amount,
TableRoot: cmd.TableRoot,
NewAvailableBalance: &examples.Currency{Amount: newAvailable, CurrencyCode: "CHIPS"},
NewReservedBalance: &examples.Currency{Amount: newReserved, CurrencyCode: "CHIPS"},
ReservedAt: timestamppb.New(time.Now()),
}
}
// HandleReserveFunds handles the ReserveFunds command (reserve funds for table buy-in).
func HandleReserveFunds(
commandBook *pb.CommandBook,
commandAny *anypb.Any,
state PlayerState,
seq uint32,
) (*pb.EventBook, error) {
var cmd examples.ReserveFunds
if err := proto.Unmarshal(commandAny.Value, &cmd); err != nil {
return nil, err
}
if err := guardReserveFunds(state); err != nil {
return nil, err
}
amount, err := validateReserveFunds(&cmd, state)
if err != nil {
return nil, err
}
event := computeFundsReserved(&cmd, state, amount)
eventAny, err := anypb.New(event)
if err != nil {
return nil, err
}
return angzarr.NewEventBook(commandBook.Cover, seq, eventAny), nil
}
examples::FundsReserved handle_reserve(const examples::ReserveFunds& cmd,
const PlayerState& state) {
// Guard
if (!state.exists()) {
throw angzarr::CommandRejectedError::precondition_failed("Player does not exist");
}
// Validate
int64_t amount = cmd.has_amount() ? cmd.amount().amount() : 0;
if (amount <= 0) {
throw angzarr::CommandRejectedError::invalid_argument("amount must be positive");
}
std::string table_key = bytes_to_hex(cmd.table_root());
if (state.table_reservations.count(table_key) > 0) {
throw angzarr::CommandRejectedError::precondition_failed(
"Funds already reserved for this table");
}
if (amount > state.available_balance()) {
throw angzarr::CommandRejectedError::precondition_failed("Insufficient funds");
}
// Compute
int64_t new_reserved = state.reserved_funds + amount;
int64_t new_available = state.bankroll - new_reserved;
examples::FundsReserved event;
event.mutable_amount()->CopyFrom(cmd.amount());
event.set_table_root(cmd.table_root());
event.mutable_new_available_balance()->set_amount(new_available);
event.mutable_new_available_balance()->set_currency_code("CHIPS");
event.mutable_new_reserved_balance()->set_amount(new_reserved);
event.mutable_new_reserved_balance()->set_currency_code("CHIPS");
auto now = std::chrono::system_clock::now();
auto seconds = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
event.mutable_reserved_at()->set_seconds(seconds);
return event;
}
- Python
- C#
- Rust
- Java
- Go
- C++
@handles(table_proto.CreateTable)
def create(self, cmd: table_proto.CreateTable) -> table_proto.TableCreated:
"""Create a new table."""
if self.exists:
raise CommandRejectedError("Table already exists")
if not cmd.table_name:
raise CommandRejectedError("table_name is required")
if cmd.small_blind <= 0:
raise CommandRejectedError("small_blind must be positive")
if cmd.big_blind <= 0:
raise CommandRejectedError("big_blind must be positive")
if cmd.big_blind < cmd.small_blind:
raise CommandRejectedError("big_blind must be >= small_blind")
if cmd.max_players < 2 or cmd.max_players > 10:
raise CommandRejectedError("max_players must be between 2 and 10")
return table_proto.TableCreated(
table_name=cmd.table_name,
game_variant=cmd.game_variant,
small_blind=cmd.small_blind,
big_blind=cmd.big_blind,
min_buy_in=cmd.min_buy_in or cmd.big_blind * 20,
max_buy_in=cmd.max_buy_in or cmd.big_blind * 100,
max_players=cmd.max_players or 9,
action_timeout_seconds=cmd.action_timeout_seconds or 30,
created_at=now(),
)
[CommandHandler]
public FundsReserved HandleReserve(ReserveFunds cmd)
{
var newReserved = State.ReservedFunds + cmd.Amount;
return new FundsReserved { Amount = cmd.Amount, NewReserved = newReserved };
}
#[aggregate(name = "table", domain = "table", state = TableState)]
impl TableAggregate {
#[handles(CreateTable)]
fn handle_create(
&self,
_cmd: &CommandBook,
create: &CreateTable,
state: &TableState,
seq: u32,
) -> Result<EventBook, String> {
// Guard: table must not exist
if !state.table_name.is_empty() {
return Err("Table already exists".into());
}
// Validate: required fields
if create.table_name.is_empty() {
return Err("table_name is required".into());
}
if create.small_blind == 0 {
return Err("small_blind must be positive".into());
}
// Compute: create the event
Ok(EventBook::single_event(
"table",
"TableCreated",
seq,
TableCreated {
table_name: create.table_name.clone(),
small_blind: create.small_blind,
big_blind: create.big_blind,
max_players: create.max_players,
..Default::default()
}
.encode_to_vec(),
))
}
#[applies(TableCreated)]
fn apply_created(state: &mut TableState, event: &TableCreated) {
state.table_name = event.table_name.clone();
state.small_blind = event.small_blind;
state.big_blind = event.big_blind;
state.max_players = event.max_players;
}
}
@CommandHandler
public FundsReserved handleReserve(ReserveFunds cmd) {
long newReserved = state.getReservedFunds() + cmd.getAmount();
return new FundsReserved(cmd.getAmount(), newReserved);
}
// NewHand creates a new Hand command handler with prior events for state reconstruction.
func NewHand(eventBook *pb.EventBook) *Hand {
h := &Hand{}
h.Init(eventBook, func() HandState {
return HandState{
Players: make(map[string]*PlayerHandState),
Pots: []*PotState{{PotType: "main"}},
}
})
h.SetDomain("hand")
// Register event appliers
h.Applies(h.applyCardsDealt)
h.Applies(h.applyBlindPosted)
h.Applies(h.applyActionTaken)
h.Applies(h.applyBettingRoundComplete)
h.Applies(h.applyCommunityCardsDealt)
h.Applies(h.applyDrawCompleted)
h.Applies(h.applyShowdownStarted)
h.Applies(h.applyCardsRevealed)
h.Applies(h.applyCardsMucked)
h.Applies(h.applyPotAwarded)
h.Applies(h.applyHandComplete)
// Register command handlers
h.Handles(h.dealCards)
h.Handles(h.postBlind)
h.Handles(h.playerAction)
h.Handles(h.dealCommunityCards)
h.Handles(h.requestDraw)
h.Handles(h.revealCards)
h.HandlesMulti(h.awardPot)
return h
}
Player::Player() {
// Register command handlers
register_handler<examples::RegisterPlayer, examples::PlayerRegistered>(
[this](const examples::RegisterPlayer& cmd, const PlayerState&) {
return handle_register(cmd);
});
register_handler<examples::DepositFunds, examples::FundsDeposited>(
[this](const examples::DepositFunds& cmd, const PlayerState&) {
return handle_deposit(cmd);
});
register_handler<examples::WithdrawFunds, examples::FundsWithdrawn>(
[this](const examples::WithdrawFunds& cmd, const PlayerState&) {
return handle_withdraw(cmd);
});
register_handler<examples::ReserveFunds, examples::FundsReserved>(
[this](const examples::ReserveFunds& cmd, const PlayerState&) {
return handle_reserve(cmd);
});
register_handler<examples::ReleaseFunds, examples::FundsReleased>(
[this](const examples::ReleaseFunds& cmd, const PlayerState&) {
return handle_release(cmd);
});
register_handler<examples::TransferFunds, examples::FundsTransferred>(
[this](const examples::TransferFunds& cmd, const PlayerState&) {
return handle_transfer(cmd);
});
}
examples::PlayerRegistered Player::handle_register(const examples::RegisterPlayer& cmd) {
return handlers::handle_register(cmd, state_);
}
examples::FundsDeposited Player::handle_deposit(const examples::DepositFunds& cmd) {
return handlers::handle_deposit(cmd, state_);
}
examples::FundsWithdrawn Player::handle_withdraw(const examples::WithdrawFunds& cmd) {
return handlers::handle_withdraw(cmd, state_);
}
examples::FundsReserved Player::handle_reserve(const examples::ReserveFunds& cmd) {
return handlers::handle_reserve(cmd, state_);
}
examples::FundsReleased Player::handle_release(const examples::ReleaseFunds& cmd) {
return handlers::handle_release(cmd, state_);
}
examples::FundsTransferred Player::handle_transfer(const examples::TransferFunds& cmd) {
return handlers::handle_transfer(cmd, state_);
}
State Building
State is rebuilt by applying events. ⍼ Angzarr provides two patterns:
| Pattern | Description | Best For |
|---|---|---|
| Functional (StateRouter) | Fluent builder with typed event handlers | Clean separation, easy testing |
OO with @applies | Decorators on aggregate class methods | Rich domain models |
- Functional
- OO
StateRouter provides fluent registration of event appliers, keeping state reconstruction separate from command handling.
- Python
- C#
- Rust
- Java
- Go
- C++
def apply_registered(state: PlayerState, event: player.PlayerRegistered) -> None:
"""Apply PlayerRegistered event to state."""
state.player_id = f"player_{event.email}"
state.display_name = event.display_name
state.email = event.email
state.player_type = event.player_type
state.ai_model_id = event.ai_model_id
state.status = "active"
state.bankroll = 0
state.reserved_funds = 0
def apply_deposited(state: PlayerState, event: player.FundsDeposited) -> None:
"""Apply FundsDeposited event to state."""
if event.new_balance:
state.bankroll = event.new_balance.amount
def apply_withdrawn(state: PlayerState, event: player.FundsWithdrawn) -> None:
"""Apply FundsWithdrawn event to state."""
if event.new_balance:
state.bankroll = event.new_balance.amount
def apply_reserved(state: PlayerState, event: player.FundsReserved) -> None:
"""Apply FundsReserved event to state."""
if event.new_reserved_balance:
state.reserved_funds = event.new_reserved_balance.amount
table_key = event.table_root.hex()
if event.amount:
state.table_reservations[table_key] = event.amount.amount
def apply_released(state: PlayerState, event: player.FundsReleased) -> None:
"""Apply FundsReleased event to state."""
if event.new_reserved_balance:
state.reserved_funds = event.new_reserved_balance.amount
table_key = event.table_root.hex()
state.table_reservations.pop(table_key, None)
def apply_transferred(state: PlayerState, event: player.FundsTransferred) -> None:
"""Apply FundsTransferred event to state."""
if event.new_balance:
state.bankroll = event.new_balance.amount
public static readonly StateRouter<PlayerState> Router = new StateRouter<PlayerState>()
.On<PlayerRegistered>(
(state, evt) =>
{
state.PlayerId = $"player_{evt.Email}";
state.DisplayName = evt.DisplayName;
state.Email = evt.Email;
state.PlayerType = evt.PlayerType;
state.AiModelId = evt.AiModelId;
state.Status = "active";
state.Bankroll = 0;
state.ReservedFunds = 0;
}
)
.On<FundsDeposited>(
(state, evt) =>
{
if (evt.NewBalance != null)
{
state.Bankroll = evt.NewBalance.Amount;
}
}
)
.On<FundsWithdrawn>(
(state, evt) =>
{
if (evt.NewBalance != null)
{
state.Bankroll = evt.NewBalance.Amount;
}
}
)
.On<FundsReserved>(
(state, evt) =>
{
if (evt.NewReservedBalance != null)
{
state.ReservedFunds = evt.NewReservedBalance.Amount;
}
var tableKey = Convert.ToHexString(evt.TableRoot.ToByteArray()).ToLowerInvariant();
if (evt.Amount != null)
{
state.TableReservations[tableKey] = evt.Amount.Amount;
}
}
)
.On<FundsReleased>(
(state, evt) =>
{
if (evt.NewReservedBalance != null)
{
state.ReservedFunds = evt.NewReservedBalance.Amount;
}
var tableKey = Convert.ToHexString(evt.TableRoot.ToByteArray()).ToLowerInvariant();
state.TableReservations.Remove(tableKey);
}
)
.On<FundsTransferred>(
(state, evt) =>
{
if (evt.NewBalance != null)
{
state.Bankroll = evt.NewBalance.Amount;
}
}
);
fn apply_registered(state: &mut PlayerState, event: PlayerRegistered) {
state.player_id = format!("player_{}", event.email);
state.display_name = event.display_name;
state.email = event.email;
state.player_type = PlayerType::try_from(event.player_type).unwrap_or_default();
state.ai_model_id = event.ai_model_id;
state.status = "active".to_string();
state.bankroll = 0;
state.reserved_funds = 0;
}
fn apply_deposited(state: &mut PlayerState, event: FundsDeposited) {
if let Some(balance) = event.new_balance {
state.bankroll = balance.amount;
}
}
fn apply_withdrawn(state: &mut PlayerState, event: FundsWithdrawn) {
if let Some(balance) = event.new_balance {
state.bankroll = balance.amount;
}
}
fn apply_reserved(state: &mut PlayerState, event: FundsReserved) {
if let Some(balance) = event.new_reserved_balance {
state.reserved_funds = balance.amount;
}
if let (Some(amount), table_root) = (event.amount, event.table_root) {
let table_key = hex::encode(&table_root);
state.table_reservations.insert(table_key, amount.amount);
}
}
fn apply_released(state: &mut PlayerState, event: FundsReleased) {
if let Some(balance) = event.new_reserved_balance {
state.reserved_funds = balance.amount;
}
let table_key = hex::encode(&event.table_root);
state.table_reservations.remove(&table_key);
}
fn apply_transferred(state: &mut PlayerState, event: FundsTransferred) {
if let Some(balance) = event.new_balance {
state.bankroll = balance.amount;
}
}
// --- Buy-in orchestration events ---
fn apply_buy_in_requested(state: &mut PlayerState, event: BuyInRequested) {
let reservation_hex = hex::encode(&event.reservation_id);
let amount = event.amount.as_ref().map(|c| c.amount).unwrap_or(0);
// Reserve funds for this buy-in
state.reserved_funds += amount;
state.pending_buy_ins.insert(
reservation_hex,
PendingBuyIn {
table_root: event.table_root,
seat: event.seat,
amount,
},
);
}
fn apply_buy_in_confirmed(state: &mut PlayerState, event: BuyInConfirmed) {
let reservation_hex = hex::encode(&event.reservation_id);
if let Some(pending) = state.pending_buy_ins.remove(&reservation_hex) {
// Move from reserved to table reservation
state.reserved_funds -= pending.amount;
let table_key = hex::encode(&pending.table_root);
state.table_reservations.insert(table_key, pending.amount);
// Deduct from bankroll (funds are now at the table)
state.bankroll -= pending.amount;
}
}
fn apply_buy_in_released(state: &mut PlayerState, event: BuyInReservationReleased) {
let reservation_hex = hex::encode(&event.reservation_id);
if let Some(pending) = state.pending_buy_ins.remove(&reservation_hex) {
// Release reserved funds back to available
state.reserved_funds -= pending.amount;
}
}
// --- Registration orchestration events ---
fn apply_registration_requested(state: &mut PlayerState, event: RegistrationRequested) {
let reservation_hex = hex::encode(&event.reservation_id);
let fee = event.fee.as_ref().map(|c| c.amount).unwrap_or(0);
// Reserve funds for registration fee
state.reserved_funds += fee;
state.pending_registrations.insert(
reservation_hex,
PendingRegistration {
tournament_root: event.tournament_root,
fee,
},
);
}
fn apply_registration_confirmed(state: &mut PlayerState, event: RegistrationFeeConfirmed) {
let reservation_hex = hex::encode(&event.reservation_id);
if let Some(pending) = state.pending_registrations.remove(&reservation_hex) {
// Deduct fee from reserved and bankroll
state.reserved_funds -= pending.fee;
state.bankroll -= pending.fee;
}
}
fn apply_registration_released(state: &mut PlayerState, event: RegistrationFeeReleased) {
let reservation_hex = hex::encode(&event.reservation_id);
if let Some(pending) = state.pending_registrations.remove(&reservation_hex) {
// Release reserved funds
state.reserved_funds -= pending.fee;
}
}
// --- Rebuy orchestration events ---
fn apply_rebuy_requested(state: &mut PlayerState, event: RebuyRequested) {
let reservation_hex = hex::encode(&event.reservation_id);
let fee = event.fee.as_ref().map(|c| c.amount).unwrap_or(0);
// Reserve funds for rebuy fee
state.reserved_funds += fee;
state.pending_rebuys.insert(
reservation_hex,
PendingRebuy {
tournament_root: event.tournament_root,
table_root: event.table_root,
seat: event.seat,
fee,
chips_to_add: 0, // Will be set by PM
},
);
}
fn apply_rebuy_confirmed(state: &mut PlayerState, event: RebuyFeeConfirmed) {
let reservation_hex = hex::encode(&event.reservation_id);
if let Some(pending) = state.pending_rebuys.remove(&reservation_hex) {
// Deduct fee from reserved and bankroll
state.reserved_funds -= pending.fee;
state.bankroll -= pending.fee;
}
}
fn apply_rebuy_released(state: &mut PlayerState, event: RebuyFeeReleased) {
let reservation_hex = hex::encode(&event.reservation_id);
if let Some(pending) = state.pending_rebuys.remove(&reservation_hex) {
// Release reserved funds
state.reserved_funds -= pending.fee;
}
}
/// StateRouter for fluent state reconstruction.
///
/// Type names are extracted via reflection using `prost::Name::full_name()`.
pub static STATE_ROUTER: LazyLock<StateRouter<PlayerState>> = LazyLock::new(|| {
StateRouter::new()
.on::<PlayerRegistered>(apply_registered)
.on::<FundsDeposited>(apply_deposited)
.on::<FundsWithdrawn>(apply_withdrawn)
.on::<FundsReserved>(apply_reserved)
.on::<FundsReleased>(apply_released)
.on::<FundsTransferred>(apply_transferred)
// Buy-in orchestration
.on::<BuyInRequested>(apply_buy_in_requested)
.on::<BuyInConfirmed>(apply_buy_in_confirmed)
.on::<BuyInReservationReleased>(apply_buy_in_released)
// Registration orchestration
.on::<RegistrationRequested>(apply_registration_requested)
.on::<RegistrationFeeConfirmed>(apply_registration_confirmed)
.on::<RegistrationFeeReleased>(apply_registration_released)
// Rebuy orchestration
.on::<RebuyRequested>(apply_rebuy_requested)
.on::<RebuyFeeConfirmed>(apply_rebuy_confirmed)
.on::<RebuyFeeReleased>(apply_rebuy_released)
});
public final class StateBuilder {
private StateBuilder() {}
/** Build state from event book by replaying all events. */
public static PlayerState fromEventBook(EventBook eventBook) {
PlayerState state = new PlayerState();
if (eventBook == null) {
return state;
}
for (EventPage page : eventBook.getPagesList()) {
applyEvent(state, page.getEvent());
}
return state;
}
/** Apply a single event to state. */
public static void applyEvent(PlayerState state, Any eventAny) {
String typeUrl = eventAny.getTypeUrl();
try {
if (typeUrl.endsWith("PlayerRegistered")) {
PlayerRegistered event = eventAny.unpack(PlayerRegistered.class);
state.setPlayerId("player_" + event.getEmail());
state.setDisplayName(event.getDisplayName());
state.setEmail(event.getEmail());
state.setPlayerType(event.getPlayerTypeValue());
state.setAiModelId(event.getAiModelId());
state.setStatus("active");
state.setBankroll(0);
state.setReservedFunds(0);
} else if (typeUrl.endsWith("FundsDeposited")) {
FundsDeposited event = eventAny.unpack(FundsDeposited.class);
if (event.hasNewBalance()) {
state.setBankroll(event.getNewBalance().getAmount());
}
} else if (typeUrl.endsWith("FundsWithdrawn")) {
FundsWithdrawn event = eventAny.unpack(FundsWithdrawn.class);
if (event.hasNewBalance()) {
state.setBankroll(event.getNewBalance().getAmount());
}
} else if (typeUrl.endsWith("FundsReserved")) {
FundsReserved event = eventAny.unpack(FundsReserved.class);
if (event.hasNewReservedBalance()) {
state.setReservedFunds(event.getNewReservedBalance().getAmount());
}
String tableKey = bytesToHex(event.getTableRoot().toByteArray());
if (event.hasAmount()) {
state.getTableReservations().put(tableKey, event.getAmount().getAmount());
}
} else if (typeUrl.endsWith("FundsReleased")) {
FundsReleased event = eventAny.unpack(FundsReleased.class);
if (event.hasNewReservedBalance()) {
state.setReservedFunds(event.getNewReservedBalance().getAmount());
}
String tableKey = bytesToHex(event.getTableRoot().toByteArray());
state.getTableReservations().remove(tableKey);
}
} catch (InvalidProtocolBufferException e) {
throw new RuntimeException("Failed to unpack event: " + typeUrl, e);
}
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
func applyRegistered(state *PlayerState, event *examples.PlayerRegistered) {
state.PlayerID = "player_" + event.Email
state.DisplayName = event.DisplayName
state.Email = event.Email
state.PlayerType = event.PlayerType
state.AIModelID = event.AiModelId
state.Status = "active"
state.Bankroll = 0
state.ReservedFunds = 0
}
// applyDeposited applies a FundsDeposited event to state.
//
// # Why Events Carry Final State (Not Deltas)
//
// Events contain NewBalance (the result) rather than delta (amount deposited).
// This design choice provides:
// 1. **Idempotent replay**: Re-applying the event produces the same state
// 2. **Auditable**: Can verify the computation was correct at event time
// 3. **Simpler appliers**: Just assign the value, no arithmetic needed
//
// The trade-off: events are slightly larger, and you can't easily see the
// delta without comparing to previous state. For most use cases, the benefits
// of idempotent replay outweigh this.
func applyDeposited(state *PlayerState, event *examples.FundsDeposited) {
if event.NewBalance != nil {
state.Bankroll = event.NewBalance.Amount
}
}
func applyWithdrawn(state *PlayerState, event *examples.FundsWithdrawn) {
if event.NewBalance != nil {
state.Bankroll = event.NewBalance.Amount
}
}
func applyReserved(state *PlayerState, event *examples.FundsReserved) {
if event.NewReservedBalance != nil {
state.ReservedFunds = event.NewReservedBalance.Amount
}
if event.TableRoot != nil && event.Amount != nil {
tableKey := hex.EncodeToString(event.TableRoot)
state.TableReservations[tableKey] = event.Amount.Amount
}
}
func applyReleased(state *PlayerState, event *examples.FundsReleased) {
if event.NewReservedBalance != nil {
state.ReservedFunds = event.NewReservedBalance.Amount
}
if event.TableRoot != nil {
tableKey := hex.EncodeToString(event.TableRoot)
delete(state.TableReservations, tableKey)
}
}
func applyTransferred(state *PlayerState, event *examples.FundsTransferred) {
if event.NewBalance != nil {
state.Bankroll = event.NewBalance.Amount
}
}
// stateRouter is the fluent state reconstruction router.
var stateRouter = angzarr.NewStateRouter(NewPlayerState).
On(applyRegistered).
On(applyDeposited).
On(applyWithdrawn).
On(applyReserved).
On(applyReleased).
On(applyTransferred)
PlayerState PlayerState::from_event_book(const angzarr::EventBook* event_book) {
PlayerState state;
if (event_book != nullptr) {
for (const auto& page : event_book->pages()) {
apply_event(state, page.event());
}
}
return state;
}
PlayerState PlayerState::from_event_book(const angzarr::EventBook& event_book) {
return from_event_book(&event_book);
}
void PlayerState::apply_event(PlayerState& state, const google::protobuf::Any& event_any) {
const std::string& type_url = event_any.type_url();
if (ends_with(type_url, "PlayerRegistered")) {
examples::PlayerRegistered event;
if (event_any.UnpackTo(&event)) {
state.player_id = "player_" + event.email();
state.display_name = event.display_name();
state.email = event.email();
state.player_type = event.player_type();
state.ai_model_id = event.ai_model_id();
state.status = "active";
state.bankroll = 0;
state.reserved_funds = 0;
}
} else if (ends_with(type_url, "FundsDeposited")) {
examples::FundsDeposited event;
if (event_any.UnpackTo(&event)) {
if (event.has_new_balance()) {
state.bankroll = event.new_balance().amount();
}
}
} else if (ends_with(type_url, "FundsWithdrawn")) {
examples::FundsWithdrawn event;
if (event_any.UnpackTo(&event)) {
if (event.has_new_balance()) {
state.bankroll = event.new_balance().amount();
}
}
} else if (ends_with(type_url, "FundsReserved")) {
examples::FundsReserved event;
if (event_any.UnpackTo(&event)) {
if (event.has_new_reserved_balance()) {
state.reserved_funds = event.new_reserved_balance().amount();
}
std::string table_key = bytes_to_hex(event.table_root());
if (event.has_amount()) {
state.table_reservations[table_key] = event.amount().amount();
}
}
} else if (ends_with(type_url, "FundsReleased")) {
examples::FundsReleased event;
if (event_any.UnpackTo(&event)) {
if (event.has_new_reserved_balance()) {
state.reserved_funds = event.new_reserved_balance().amount();
}
std::string table_key = bytes_to_hex(event.table_root());
state.table_reservations.erase(table_key);
}
} else if (ends_with(type_url, "FundsTransferred")) {
examples::FundsTransferred event;
if (event_any.UnpackTo(&event)) {
if (event.has_new_balance()) {
state.bankroll = event.new_balance().amount();
}
}
}
}
OO aggregates use @applies/[Applies] decorators on methods within the aggregate class.
- Python
- C#
- Rust
- Java
- Go
- C++
@handles(table_proto.CreateTable)
def create(self, cmd: table_proto.CreateTable) -> table_proto.TableCreated:
"""Create a new table."""
if self.exists:
raise CommandRejectedError("Table already exists")
if not cmd.table_name:
raise CommandRejectedError("table_name is required")
if cmd.small_blind <= 0:
raise CommandRejectedError("small_blind must be positive")
if cmd.big_blind <= 0:
raise CommandRejectedError("big_blind must be positive")
if cmd.big_blind < cmd.small_blind:
raise CommandRejectedError("big_blind must be >= small_blind")
if cmd.max_players < 2 or cmd.max_players > 10:
raise CommandRejectedError("max_players must be between 2 and 10")
return table_proto.TableCreated(
table_name=cmd.table_name,
game_variant=cmd.game_variant,
small_blind=cmd.small_blind,
big_blind=cmd.big_blind,
min_buy_in=cmd.min_buy_in or cmd.big_blind * 20,
max_buy_in=cmd.max_buy_in or cmd.big_blind * 100,
max_players=cmd.max_players or 9,
action_timeout_seconds=cmd.action_timeout_seconds or 30,
created_at=now(),
)
[Applies]
public void Apply(FundsDeposited e) => Bankroll += e.Amount;
[Applies]
public void Apply(FundsReserved e) => ReservedFunds += e.Amount;
#[aggregate(name = "table", domain = "table", state = TableState)]
impl TableAggregate {
#[handles(CreateTable)]
fn handle_create(
&self,
_cmd: &CommandBook,
create: &CreateTable,
state: &TableState,
seq: u32,
) -> Result<EventBook, String> {
// Guard: table must not exist
if !state.table_name.is_empty() {
return Err("Table already exists".into());
}
// Validate: required fields
if create.table_name.is_empty() {
return Err("table_name is required".into());
}
if create.small_blind == 0 {
return Err("small_blind must be positive".into());
}
// Compute: create the event
Ok(EventBook::single_event(
"table",
"TableCreated",
seq,
TableCreated {
table_name: create.table_name.clone(),
small_blind: create.small_blind,
big_blind: create.big_blind,
max_players: create.max_players,
..Default::default()
}
.encode_to_vec(),
))
}
#[applies(TableCreated)]
fn apply_created(state: &mut TableState, event: &TableCreated) {
state.table_name = event.table_name.clone();
state.small_blind = event.small_blind;
state.big_blind = event.big_blind;
state.max_players = event.max_players;
}
}
@Applies
public void apply(FundsDeposited e) { bankroll += e.getAmount(); }
@Applies
public void apply(FundsReserved e) { reservedFunds += e.getAmount(); }
// NewHand creates a new Hand command handler with prior events for state reconstruction.
func NewHand(eventBook *pb.EventBook) *Hand {
h := &Hand{}
h.Init(eventBook, func() HandState {
return HandState{
Players: make(map[string]*PlayerHandState),
Pots: []*PotState{{PotType: "main"}},
}
})
h.SetDomain("hand")
// Register event appliers
h.Applies(h.applyCardsDealt)
h.Applies(h.applyBlindPosted)
h.Applies(h.applyActionTaken)
h.Applies(h.applyBettingRoundComplete)
h.Applies(h.applyCommunityCardsDealt)
h.Applies(h.applyDrawCompleted)
h.Applies(h.applyShowdownStarted)
h.Applies(h.applyCardsRevealed)
h.Applies(h.applyCardsMucked)
h.Applies(h.applyPotAwarded)
h.Applies(h.applyHandComplete)
// Register command handlers
h.Handles(h.dealCards)
h.Handles(h.postBlind)
h.Handles(h.playerAction)
h.Handles(h.dealCommunityCards)
h.Handles(h.requestDraw)
h.Handles(h.revealCards)
h.HandlesMulti(h.awardPot)
return h
}
Player::Player() {
// Register command handlers
register_handler<examples::RegisterPlayer, examples::PlayerRegistered>(
[this](const examples::RegisterPlayer& cmd, const PlayerState&) {
return handle_register(cmd);
});
register_handler<examples::DepositFunds, examples::FundsDeposited>(
[this](const examples::DepositFunds& cmd, const PlayerState&) {
return handle_deposit(cmd);
});
register_handler<examples::WithdrawFunds, examples::FundsWithdrawn>(
[this](const examples::WithdrawFunds& cmd, const PlayerState&) {
return handle_withdraw(cmd);
});
register_handler<examples::ReserveFunds, examples::FundsReserved>(
[this](const examples::ReserveFunds& cmd, const PlayerState&) {
return handle_reserve(cmd);
});
register_handler<examples::ReleaseFunds, examples::FundsReleased>(
[this](const examples::ReleaseFunds& cmd, const PlayerState&) {
return handle_release(cmd);
});
register_handler<examples::TransferFunds, examples::FundsTransferred>(
[this](const examples::TransferFunds& cmd, const PlayerState&) {
return handle_transfer(cmd);
});
}
examples::PlayerRegistered Player::handle_register(const examples::RegisterPlayer& cmd) {
return handlers::handle_register(cmd, state_);
}
examples::FundsDeposited Player::handle_deposit(const examples::DepositFunds& cmd) {
return handlers::handle_deposit(cmd, state_);
}
examples::FundsWithdrawn Player::handle_withdraw(const examples::WithdrawFunds& cmd) {
return handlers::handle_withdraw(cmd, state_);
}
examples::FundsReserved Player::handle_reserve(const examples::ReserveFunds& cmd) {
return handlers::handle_reserve(cmd, state_);
}
examples::FundsReleased Player::handle_release(const examples::ReleaseFunds& cmd) {
return handlers::handle_release(cmd, state_);
}
examples::FundsTransferred Player::handle_transfer(const examples::TransferFunds& cmd) {
return handlers::handle_transfer(cmd, state_);
}
Testing
⍼ Angzarr uses Gherkin feature files as living specifications. The same feature files run against all language implementations, guaranteeing identical business behavior.
Example Gherkin feature:
Feature: Player fund reservation
Players must reserve funds when joining a table to ensure
they can cover their buy-in before sitting down.
Scenario: Reserve funds for table buy-in
Given a player with $500 available
When the player reserves $200 for a table
Then the player's available balance is $300
And the player's reserved balance is $200
Scenario: Cannot reserve more than available
Given a player with $500 available
When the player tries to reserve $600
Then the request fails with "insufficient funds"
And the player's available balance remains $500
The guard → validate → compute pattern makes handlers pure functions, directly testable without mocks:
def test_deposit_increases_bankroll():
state = PlayerState(registered=True, bankroll=1000)
cmd = DepositFunds(amount=500)
event = compute_deposit(cmd, state)
assert event.new_bankroll == 1500
See Testing for the full three-level testing strategy.
Next Steps
- Sagas — Cross-domain coordination
- Projectors — Read-side views
- Testing — Full test strategy
- Why Poker — Domain rationale