Bank Account Example
A complete CQRS application demonstrating aggregations, projections, commands with validation, and multiple read models.
Events
class AccountOpened { has UInt $.account-id; has Rat $.initial-balance }
class AmountDeposited { has UInt $.account-id; has Rat $.amount }
class AmountWithdrawn { has UInt $.account-id; has Rat $.amount }
class AccountClosed { has UInt $.account-id }
Aggregation
aggregation BankAccount {
has UInt $.account-id is projection-id;
has Rat $.balance = 0;
has Bool $.is-open = False;
method apply(AccountOpened $e) {
$!balance = $e.initial-balance;
$!is-open = True;
}
method apply(AmountDeposited $e) { $!balance += $e.amount }
method apply(AmountWithdrawn $e) { $!balance -= $e.amount }
method apply(AccountClosed $e) { $!is-open = False }
method deposit(Rat $amount) is command {
die "Account is closed" unless $!is-open;
die "Amount must be positive" if $amount <= 0;
$.amount-deposited: :$amount;
}
method withdraw(Rat $amount) is command {
die "Account is closed" unless $!is-open;
die "Insufficient funds: $!balance < $amount" if $!balance < $amount;
$.amount-withdrawn: :$amount;
}
method close() is command {
die "Account is already closed" unless $!is-open;
$.account-closed;
}
}
Projections
# Balance view
projection BalanceView {
has UInt $.account-id is projection-id;
has Rat $.balance = 0;
method apply(AccountOpened $e) { $!balance = $e.initial-balance }
method apply(AmountDeposited $e) { $!balance += $e.amount }
method apply(AmountWithdrawn $e) { $!balance -= $e.amount }
}
# Transaction history
projection TransactionLog {
has UInt $.account-id is projection-id;
has Str @.transactions;
method apply(AccountOpened $e) {
@!transactions.push: "Opened with {$e.initial-balance}";
}
method apply(AmountDeposited $e) {
@!transactions.push: "Deposited {$e.amount}";
}
method apply(AmountWithdrawn $e) {
@!transactions.push: "Withdrew {$e.amount}";
}
method apply(AccountClosed $e) {
@!transactions.push: "Account closed";
}
}
Usage
use Sourcing;
use Sourcing::Plugin::Memory;
Sourcing::Plugin::Memory.use;
# Open an account
my $account = sourcing BankAccount, :account-id(1);
$account.account-opened: initial-balance => 1000;
$account.^update;
# Make transactions
$account.deposit: 500;
$account.^update;
$account.withdraw: 200;
$account.^update;
# Query projections
my $balance = sourcing BalanceView, :account-id(1);
say "Balance: {$balance.balance}"; # 1300
my $log = sourcing TransactionLog, :account-id(1);
say "Transactions:";
for $log.transactions -> $t {
say " $t";
}
# Opened with 1000
# Deposited 500
# Withdrew 200
Key Concepts Demonstrated
- Multiple projections from same events — BalanceView and TransactionLog both consume the same event stream
- Command validation —
withdrawchecks balance before emitting - Optimistic locking — Concurrent deposits/withdrawals are handled via automatic retry
- Event-driven state — All state changes come from events, never from direct mutation
Next Steps
- See the Shopping Cart Example for a different domain
- Learn about Optimistic Locking in detail