跳到正文

指南

领域建模

以 bank Account 为例的 go-sequent 分步教程:Event、Command、带 applier 与不变量的 Aggregate、Handler、SyncInTxn 余额 Projector,以及接线运行。

领域建模

本教程沿用 Sequent 参考指南的顺序:先写 Event,再写 Command,然后是执行规则的 Aggregate,最后是 Handler 与读模型。领域对象是 examples/bank 里的银行 Account。每段代码取自该包,并为篇幅做了精简。

你将构建四个命令(开户、存款、取款、转账)和五个事件。

1. 事件:事实

事件是关于某个聚合的过去式事实。嵌入 event.Base,给事件一个类型名,保持 payload 扁平。金额用 types.Money,JSON 里是 decimal 字符串。

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 }

五个事件是 AccountOpened、MoneyDeposited、MoneyWithdrawn、TransferDebited 与 TransferCredited。为每个事件注册一个工厂,让存储能把 JSON 解码回带类型的事件:

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 会填充聚合 id、序号和时间戳,你不需要手动设置。见 Event。

2. 命令:请求

命令是修改状态的请求。嵌入 command.Base,声明目标聚合,并校验自身形状。

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 只检查命令自身就能判断的内容。依赖状态的规则(例如「余额不足」)属于聚合。见 Command。

3. 聚合:规则与状态

Account 聚合嵌入 aggregate.Base。构造函数用 id 和类型名调用 Init,再用 On 为每个事件注册一个 applier。

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
    })
    // ... 其余三个 applier
    return a
}

Applier 只改状态,不做别的。新命令产生事件时它会跑,加载时重放历史也会跑,所以必须确定、且不做 I/O。币种不一致会在 applier 里 panic,因为命令方法已经拒绝过这种情况;applier 失败意味着已存历史不一致。

命令方法承载业务规则:先检查,再调用 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
}

聚合在处理命令时不得读取其他聚合。转账涉及两个账户,所以由 Handler 加载两者,并分别调用各自的方法(见下文)。

不变量与唯一键

两个可选方法让框架在提交时强制规则:

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 让「每个 owner 一个账户」成为数据库层面的保证。作用域包含租户分区键,所以同一个 owner 名可以出现在两个租户里。冲突时返回 ErrUniqueKeyViolation。见 唯一键与 OCC。

4. Handler:加载、调用、返回

Handler 把命令映射到聚合方法。它从不直接向存储追加。Repository 在 UnitOfWork 里跟踪被改过的聚合,并替你提交。

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)
}

转账是原子的,因为两个账户都在同一次 Execute 里被修改:一个事务,一个 UnitOfWork。要么两个事件都存储,要么都不存。见 Command Handler 与 Repository。

5. Projector:事务内的读模型

Projector 把事件写成查询用读模型(例如余额表)。账本余额绝不能滞后,所以这个 Projector 以 SyncInTxn(在写事务里同步更新读模型)发布,与事件在同一事务里。

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)
    // ... 转账事件
    }
    return nil
}

这个版本把余额放在 map 里,让演示不需要数据库。面向生产的 examples/bankpg 版本经 projector.Persistor 写入,它会加入写事务。ManagedTables 告诉 replay 流水线要重建哪些表。见 Projector。

6. 接线并运行

bank.NewDemoApp 用内存存储把一切接好,然后执行命令:

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,已经可见

Execute 返回时,事件已存储,余额也已更新。无需轮询——提交返回后立刻能读到自己刚写的数据。

下一步

文档随 go-sequent 源码更新;若与 API 不一致,以源码为准。