指南
领域建模
以 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 返回时,事件已存储,余额也已更新。无需轮询——提交返回后立刻能读到自己刚写的数据。
下一步
- 写路径一致性:UnitOfWork、OCC、Idempotency,以及每一步的顺序。
- Bank 示例:文件布局、Postgres 示例与测试。
- AggregateRoot 参考:完整的钩子列表。
文档随 go-sequent 源码更新;若与 API 不一致,以源码为准。