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 |
|---|---|---|
| OO (Object-Oriented) | Aggregate class with @handles/[Handles] decorators | Rich domain models, encapsulation |
| Imperative | Pure guard()/validate()/compute() functions | Simple handlers, easy testing |
| Language | OO | Imperative |
|---|---|---|
| Python | ✓ | — |
| C# | ✓ | ✓ |
| Rust | ✓ | ✓ |
| Java | ✓ | ✓ |
| Go | — | ✓ |
| C++ | — | ✓ |
Player: Reserve Funds
The Player aggregate demonstrates the two-phase reservation pattern. Funds are reserved before joining a table, then released if the join fails.
- Python OO
- C# OO
- C# Imperative
- Rust Imperative
- Java OO
- Go
- Java Imperative
examples/python/player/agg/handlers/player.py
@handles(player_proto.ReserveFunds)
def reserve(self, cmd: player_proto.ReserveFunds) -> player_proto.FundsReserved:
"""Reserve funds for a table buy-in."""
if not self.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 self.table_reservations:
raise CommandRejectedError("Funds already reserved for this table")
if amount > self.available_balance:
raise CommandRejectedError("Insufficient funds")
new_reserved = self.reserved_funds + amount
new_available = self.bankroll - new_reserved
return player_proto.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(),
)
examples/csharp/Player/Agg/Player.cs
[Handles(typeof(ReserveFunds))]
public FundsReserved HandleReserve(ReserveFunds cmd)
{
if (!Exists)
throw CommandRejectedError.PreconditionFailed("Player does not exist");
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 (TableReservations.ContainsKey(tableKey))
throw CommandRejectedError.PreconditionFailed("Funds already reserved for this table");
if (amount > AvailableBalance)
throw CommandRejectedError.PreconditionFailed("Insufficient funds");
var newReserved = ReservedFunds + amount;
var newAvailable = 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)
};
}
examples/csharp/Player/Agg/Handlers/ReserveHandler.cs
/// <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)
};
}
}
examples/rust/player/agg/src/handlers/reserve.rs
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))
}
examples/java/player/agg/src/main/java/dev/angzarr/examples/player/Player.java
@Handles(ReserveFunds.class)
public FundsReserved reserve(ReserveFunds cmd) {
// Guard
if (!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 (getState().hasReservationFor(tableKey)) {
throw Errors.CommandRejectedError.preconditionFailed("Funds already reserved for this table");
}
if (amount > getAvailableBalance()) {
throw Errors.CommandRejectedError.preconditionFailed("Insufficient funds");
}
// Compute
long newReserved = getReservedFunds() + amount;
long newAvailable = 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();
}
examples/go/player/agg/handlers/reserve.go
Go uses the imperative pattern (no OO aggregate base class):
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/java/player/agg/src/main/java/dev/angzarr/examples/player/handlers/ReserveHandler.java
/**
* 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();
}
}
State Building
State is rebuilt by applying events. ⍼ Angzarr provides two patterns:
| Pattern | Description | Best For |
|---|---|---|
OO with @applies | Decorators on aggregate class methods | Rich domain models |
| StateRouter | Fluent builder with typed event handlers | Clean separation, easy testing |
- Python OO
- C# StateRouter
- Rust StateRouter
- Java OO
- Go StateRouter
- Java StateBuilder
examples/python/player/agg/handlers/player.py
@applies(player_proto.PlayerRegistered)
def apply_registered(self, state: _PlayerState, event: player_proto.PlayerRegistered):
"""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
@applies(player_proto.FundsDeposited)
def apply_deposited(self, state: _PlayerState, event: player_proto.FundsDeposited):
"""Apply FundsDeposited event to state."""
if event.new_balance:
state.bankroll = event.new_balance.amount
@applies(player_proto.FundsWithdrawn)
def apply_withdrawn(self, state: _PlayerState, event: player_proto.FundsWithdrawn):
"""Apply FundsWithdrawn event to state."""
if event.new_balance:
state.bankroll = event.new_balance.amount
@applies(player_proto.FundsReserved)
def apply_reserved(self, state: _PlayerState, event: player_proto.FundsReserved):
"""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
@applies(player_proto.FundsReleased)
def apply_released(self, state: _PlayerState, event: player_proto.FundsReleased):
"""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)
@applies(player_proto.FundsTransferred)
def apply_transferred(self, state: _PlayerState, event: player_proto.FundsTransferred):
"""Apply FundsTransferred event to state."""
if event.new_balance:
state.bankroll = event.new_balance.amount
examples/csharp/Player/Agg/PlayerState.cs
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;
}
});
examples/rust/player/agg/src/state.rs
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;
}
}
/// StateRouter for fluent state reconstruction.
///
/// Type names are extracted via reflection using `prost::Name::full_name()`.
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)
});
examples/java/player/agg/src/main/java/dev/angzarr/examples/player/Player.java
@Applies(PlayerRegistered.class)
public void applyRegistered(PlayerState state, PlayerRegistered event) {
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);
}
@Applies(FundsDeposited.class)
public void applyDeposited(PlayerState state, FundsDeposited event) {
if (event.hasNewBalance()) {
state.setBankroll(event.getNewBalance().getAmount());
}
}
@Applies(FundsWithdrawn.class)
public void applyWithdrawn(PlayerState state, FundsWithdrawn event) {
if (event.hasNewBalance()) {
state.setBankroll(event.getNewBalance().getAmount());
}
}
@Applies(FundsReserved.class)
public void applyReserved(PlayerState state, FundsReserved event) {
if (event.hasNewReservedBalance()) {
state.setReservedFunds(event.getNewReservedBalance().getAmount());
}
String tableKey = bytesToHex(event.getTableRoot().toByteArray());
if (event.hasAmount()) {
state.getTableReservations().put(tableKey, event.getAmount().getAmount());
}
}
@Applies(FundsReleased.class)
public void applyReleased(PlayerState state, FundsReleased event) {
if (event.hasNewReservedBalance()) {
state.setReservedFunds(event.getNewReservedBalance().getAmount());
}
String tableKey = bytesToHex(event.getTableRoot().toByteArray());
state.getTableReservations().remove(tableKey);
}
examples/go/player/agg/handlers/state.go
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
}
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)
examples/java/player/agg/src/main/java/dev/angzarr/examples/player/handlers/StateBuilder.java
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();
}
}
Testing
⍼ Angzarr uses Gherkin feature files as living specifications. The same feature files run against all language implementations, guaranteeing identical business behavior.
examples/features/unit/player.feature
Feature: Player aggregate logic
The Player aggregate manages a player's bankroll and table reservations.
It's the source of truth for how much money a player has and where it's allocated.
Scenario: Register a new human player
Given no prior events for the player aggregate
When I handle a RegisterPlayer command with name "Alice" and email "alice@example.com"
Then the result is a PlayerRegistered event
And the player event has display_name "Alice"
And the player event has player_type "HUMAN"
Scenario: Cannot register player twice
Given a PlayerRegistered event for "Alice"
When I handle a RegisterPlayer command with name "Alice2" and email "alice@example.com"
Then the command fails with status "FAILED_PRECONDITION"
And the error message contains "already exists"
Scenario: Reserve funds for table buy-in
Given a PlayerRegistered event for "Alice"
And a FundsDeposited event with amount 1000
When I handle a ReserveFunds command with amount 500 for table "table-1"
Then the result is a FundsReserved event
And the player event has amount 500
And the player event has new_available_balance 500
- Python
- Rust
- Go
examples/python/tests/unit/test_player.py
import sys
from pathlib import Path
import pytest
from pytest_bdd import scenarios, given, when, then, parsers
from google.protobuf.any_pb2 import Any as ProtoAny
# Load scenarios from feature file
scenarios("../../../features/unit/player.feature")
@given(parsers.parse('a PlayerRegistered event for "{name}"'))
def player_registered_event(ctx, name):
"""Add a PlayerRegistered event."""
event = player.PlayerRegistered(
display_name=name,
email=f"{name.lower()}@example.com",
player_type=poker_types.HUMAN,
registered_at=make_timestamp(),
)
ctx.add_event(event)
@when(parsers.parse('I handle a RegisterPlayer command with name "{name}" and email "{email}"'))
def handle_register_player_cmd(ctx, name, email):
"""Handle RegisterPlayer command."""
cmd = player.RegisterPlayer(
display_name=name,
email=email,
player_type=poker_types.HUMAN,
)
_handle_command(ctx, cmd, handle_register_player)
@then("the result is a PlayerRegistered event")
def result_is_player_registered(ctx):
"""Verify result is PlayerRegistered event."""
assert ctx.result is not None, f"Expected result, got error: {ctx.error}"
assert len(ctx.result.pages) == 1
event_any = ctx.result.pages[0].event
assert event_any.type_url.endswith("PlayerRegistered")
examples/rust/tests/tests/player.rs
/// Test context for player scenarios.
#[derive(Debug, Default, World)]
#[world(init = Self::new)]
pub struct PlayerWorld {
domain: String,
root: Vec<u8>,
events: Vec<EventPage>,
next_sequence: u32,
last_error: Option<String>,
last_event_book: Option<EventBook>,
last_state: Option<PlayerState>,
}
#[given("no prior events for the player aggregate")]
fn no_prior_events(world: &mut PlayerWorld) {
world.events.clear();
world.next_sequence = 0;
}
#[given(expr = "a PlayerRegistered event for {string}")]
fn player_registered_event(world: &mut PlayerWorld, name: String) {
let event = PlayerRegistered {
display_name: name.clone(),
email: format!("{}@example.com", name.to_lowercase()),
player_type: PlayerType::Human as i32,
ai_model_id: String::new(),
registered_at: Some(angzarr_client::now()),
};
world.add_event(pack_event(&event, "examples.PlayerRegistered"));
}
#[when(expr = "I handle a RegisterPlayer command with name {string} and email {string}")]
fn handle_register_player_cmd(world: &mut PlayerWorld, name: String, email: String) {
let cmd = RegisterPlayer {
display_name: name,
email,
player_type: PlayerType::Human as i32,
ai_model_id: String::new(),
};
let cmd_any = pack_command(&cmd, "examples.RegisterPlayer");
let cmd_book = world.build_command_book(cmd_any.clone());
let state = world.rebuild_state();
match handle_register_player(&cmd_book, &cmd_any, &state, world.next_sequence) {
Ok(event_book) => {
world.last_event_book = Some(event_book);
world.last_error = None;
}
Err(e) => {
world.last_error = Some(e.to_string());
world.last_event_book = None;
}
}
}
#[then("the result is a PlayerRegistered event")]
fn result_is_player_registered(world: &mut PlayerWorld) {
let event = world.get_last_event().expect("No event found");
assert!(
event.type_url.ends_with("PlayerRegistered"),
"Expected PlayerRegistered event but got {}",
event.type_url
);
}
#[tokio::main]
async fn main() {
PlayerWorld::run("../../features/unit/player.feature").await;
}
examples/go/tests/player_test.go
// testContext holds the state for a single scenario.
type testContext struct {
domain string
root []byte
events []*pb.EventPage
nextSequence uint32
lastError error
lastEventBook *pb.EventBook
lastErrorMsg string
lastState handlers.PlayerState
}
func noPriorEventsForThePlayerAggregate(ctx context.Context) error {
tc := ctx.Value("testContext").(*testContext)
tc.events = make([]*pb.EventPage, 0)
tc.nextSequence = 0
return nil
}
func aPlayerRegisteredEventFor(ctx context.Context, name string) error {
tc := ctx.Value("testContext").(*testContext)
event := &examples.PlayerRegistered{
DisplayName: name,
Email: name + "@example.com",
PlayerType: examples.PlayerType_HUMAN,
RegisteredAt: timestamppb.New(time.Now()),
}
return tc.addEvent(event)
}
func iHandleARegisterPlayerCommand(ctx context.Context, name, email string) error {
tc := ctx.Value("testContext").(*testContext)
cmd := &examples.RegisterPlayer{
DisplayName: name,
Email: email,
PlayerType: examples.PlayerType_HUMAN,
}
cmdAny, err := anypb.New(cmd)
if err != nil {
return err
}
cmdBook := tc.buildCommandBook(cmdAny)
state := tc.rebuildState()
result, err := handlers.HandleRegisterPlayer(cmdBook, cmdAny, state, tc.nextSequence)
tc.lastEventBook = result
tc.lastError = err
if err != nil {
tc.lastErrorMsg = err.Error()
} else {
tc.lastErrorMsg = ""
}
return nil
}
func theResultIsAPlayerRegisteredEvent(ctx context.Context) error {
tc := ctx.Value("testContext").(*testContext)
eventAny, err := tc.getLastEvent()
if err != nil {
return err
}
if !angzarr.TypeURLMatches(eventAny.TypeUrl, "PlayerRegistered") {
return fmt.Errorf("expected PlayerRegistered event but got %s", eventAny.TypeUrl)
}
return nil
}
func TestFeatures(t *testing.T) {
suite := godog.TestSuite{
ScenarioInitializer: InitializeScenario,
Options: &godog.Options{
Format: "pretty",
Paths: []string{"../../features/unit/player.feature"},
TestingT: t,
},
}
if suite.Run() != 0 {
t.Fatal("non-zero status returned, failed to run feature tests")
}
}
Run tests:
# Python - pytest-bdd
cd examples/python && pytest tests/unit/test_player.py
# Rust - cucumber-rs
cd examples/rust/tests && cargo test --test player
# Go - godog
cd examples/go && go test -v ./... --godog.tags=@player
Next Steps
- Sagas — Cross-domain coordination
- Projectors — Read-side views
- Testing — Full test strategy
- Why Poker — Domain rationale