Guides
Modelling the domain
A step-by-step go-sequent tutorial using the bank Account: events, commands, the aggregate with appliers and invariants, a handler, a SyncInTxn balance projector and wiring.
Modelling the domain
This tutorial follows the order the Sequent reference guide uses: events first, then commands, then the aggregate that enforces the rules, then the handler and a read model. The domain is the bank Account from examples/bank. Every snippet is taken from that package, trimmed for length.
You will build four commands (open, deposit, withdraw, transfer) and five events.
1. Events: the facts
An event is a past-tense fact about one aggregate. Embed event.Base, give the event a type name, and keep the payload flat. Money is types.Money, which serializes as a decimal string.
const EventMoneyDeposited = "bank.MoneyDeposited"
type MoneyDeposited struct {
event.Base
Amount types.Money `json:"amount"`
}
func NewMoneyDeposited(amount types.Money) *MoneyDeposited {
e := &MoneyDeposited{Amount: amount}
e.SetEventType(EventMoneyDeposited)
return e
}
func (e *MoneyDeposited) EventType() string { return EventMoneyDeposited }
The five events are AccountOpened, MoneyDeposited, MoneyWithdrawn, TransferDebited and TransferCredited. Register a factory for each one so the store can decode JSON back into a typed event:
func RegisterEvents(r *event.Registry) {
r.Register(EventAccountOpened, func() event.Event { return &AccountOpened{} })
r.Register(EventMoneyDeposited, func() event.Event { return &MoneyDeposited{} })
// ... MoneyWithdrawn, TransferDebited, TransferCredited
}
Apply on the aggregate fills in the aggregate id, sequence number and timestamp. You never set them by hand. See Event.
2. Commands: the requests
A command is a request to change state. It embeds command.Base, names its target aggregate, and validates its own shape.
const CmdDeposit = "bank.Deposit"
type Deposit struct {
command.Base
AccountID uuid.UUID `json:"account_id"`
Amount types.Money `json:"amount"`
}
func (c *Deposit) CommandType() string { return CmdDeposit }
func (c *Deposit) AggregateID() (uuid.UUID, bool) {
return c.AccountID, c.AccountID != uuid.Nil
}
func (c *Deposit) Validate() error {
if c.AccountID == uuid.Nil {
return fmt.Errorf("account_id required")
}
if c.Amount.IsZero() || c.Amount.IsNegative() {
return fmt.Errorf("amount must be positive")
}
return nil
}
Validate checks only what the command alone can tell. Rules that depend on state, such as “insufficient funds”, belong in the aggregate. See Command.
3. The aggregate: rules and state
The Account aggregate embeds aggregate.Base. The constructor calls Init with the id and a type name, then registers one applier per event with On.
const AggregateAccount = "bank.Account"
type Account struct {
aggregate.Base
Owner string
Currency string
Balance types.Money
Open bool
}
func NewAccount(id uuid.UUID) *Account {
a := &Account{}
a.Init(id, AggregateAccount)
a.On(&AccountOpened{}, func(e event.Event) {
ev := e.(*AccountOpened)
a.Owner = ev.Owner
a.Currency = ev.Currency
a.Balance = ev.Opening
a.Open = true
a.refreshUniqueKeys()
})
a.On(&MoneyDeposited{}, func(e event.Event) {
ev := e.(*MoneyDeposited)
bal, err := a.Balance.Add(ev.Amount)
if err != nil {
panic(err)
}
a.Balance = bal
})
// ... the other three appliers
return a
}
Appliers change state and nothing else. They run both when a command emits a new event and when history is replayed on load, so they must be deterministic and free of I/O. A currency mismatch panics inside an applier because the command method has already rejected it. An applier that fails means the stored history is inconsistent.
Command methods hold the business rules. They check, then call Apply:
func (a *Account) Withdraw(amount types.Money) error {
if !a.Open {
return fmt.Errorf("account not open")
}
if amount.IsZero() || amount.IsNegative() {
return fmt.Errorf("withdraw amount must be positive")
}
if amount.Currency != a.Currency {
return fmt.Errorf("currency mismatch")
}
next, err := a.Balance.Sub(amount)
if err != nil {
return err
}
if next.IsNegative() {
return fmt.Errorf("insufficient funds")
}
a.Apply(NewMoneyWithdrawn(amount))
return nil
}
An aggregate must not read other aggregates while handling a command. A transfer needs two accounts, so the handler loads both and calls a method on each (see below).
Invariants and unique keys
Two optional methods let the framework enforce rules at commit:
func (a *Account) Invariants() error {
if a.Open && a.Balance.IsNegative() {
return fmt.Errorf("invariant: balance negative")
}
return nil
}
func (a *Account) UniqueKeys() map[string]any {
if !a.Open {
return nil
}
scope := "bank.Account.owner"
if pk := a.PartitionKey(); pk != "" {
scope = "tenant:" + pk + ":bank.Account.owner"
}
return map[string]any{scope: a.Owner}
}
UniqueKeys makes “one account per owner” a database-level guarantee. The scope includes the tenant partition key, so the same owner name can exist in two tenants. A collision returns ErrUniqueKeyViolation. See Unique keys and OCC.
4. The handler: load, call, return
The handler maps commands to aggregate methods. It never appends to the store directly. The repository tracks changed aggregates in the UnitOfWork and commits them for you.
type Handler struct {
Repo *aggregate.Repository
}
func (h *Handler) Handles() []string {
return []string{CmdOpenAccount, CmdDeposit, CmdWithdraw, CmdTransfer}
}
func (h *Handler) Handle(ctx context.Context, cmd command.Command) error {
switch c := cmd.(type) {
case *OpenAccount:
acc := NewAccount(c.AccountID)
if tid := c.Meta().TenantID; tid != uuid.Nil {
acc.SetPartitionKey(tenancy.PartitionKey(tid))
}
if err := acc.OpenAccount(c.Owner, c.Currency, c.Opening); err != nil {
return err
}
return h.Repo.Add(ctx, acc)
case *Deposit:
agg, err := h.Repo.Load(ctx, c.AccountID, AggregateAccount)
if err != nil {
return err
}
return agg.(*Account).Deposit(c.Amount)
case *Transfer:
from, err := h.Repo.Load(ctx, c.FromAccountID, AggregateAccount)
if err != nil {
return err
}
to, err := h.Repo.Load(ctx, c.ToAccountID, AggregateAccount)
if err != nil {
return err
}
fromAcc, toAcc := from.(*Account), to.(*Account)
if err := fromAcc.DebitTransfer(c.Amount, c.ToAccountID.String(), c.TransferID); err != nil {
return err
}
return toAcc.CreditTransfer(c.Amount, c.FromAccountID.String(), c.TransferID)
// ... Withdraw
}
return fmt.Errorf("bank: unhandled command %T", cmd)
}
The transfer is atomic because both accounts are changed inside one Execute: one transaction, one UnitOfWork. Either both events are stored or neither is. See Command handler and Repository.
5. The projector: a read model inside the transaction
A projector turns events into a table you can query. Ledger balances must never lag, so this one is published SyncInTxn, in the same transaction as the events.
func (p *BalanceProjector) ManagedTables() []string { return []string{"account_balances"} }
func (p *BalanceProjector) Handles() []string {
return []string{
EventAccountOpened, EventMoneyDeposited, EventMoneyWithdrawn,
EventTransferDebited, EventTransferCredited,
}
}
func (p *BalanceProjector) Handle(ctx context.Context, e event.Event) error {
switch ev := e.(type) {
case *AccountOpened:
p.balances[ev.AggregateID()] = ev.Opening
case *MoneyDeposited:
return p.add(ev.AggregateID(), ev.Amount)
case *MoneyWithdrawn:
return p.sub(ev.AggregateID(), ev.Amount)
// ... transfers
}
return nil
}
This version keeps balances in a map so the demo needs no database. The production-shaped examples/bankpg version writes through projector.Persistor, which joins the write transaction. ManagedTables tells the replay pipeline which tables to rebuild. See Projector.
6. Wire it and run it
bank.NewDemoApp wires everything with the in-memory store. Then you execute commands:
ctx := tenancy.WithTenant(context.Background(), tenantID)
open := &bank.OpenAccount{
AccountID: aliceID, Owner: "alice", Currency: "USD",
Opening: types.MustMoney("100.00", "USD"),
}
open.Meta().IdempotencyKey = "open-alice"
if err := demo.App.Service.Execute(ctx, open); err != nil {
return err
}
err := demo.App.Service.Execute(ctx, &bank.Deposit{
AccountID: aliceID, Amount: types.MustMoney("25.50", "USD"),
})
bal, _ := demo.Projector.Balance(aliceID) // 125.50 USD, already visible
When Execute returns, the events are stored and the balance is updated. That is read-your-writes without polling.
Where to go next
- Write-path consistency: the UnitOfWork, OCC, idempotency and the ordering of each step.
- Bank example: the file layout, the Postgres sample and the tests.
- AggregateRoot reference: the full list of hooks.
Docs track the go-sequent library. When APIs drift, the source wins.