《后端架构设计·架构基础与边界》3.2 订单域事件风暴工作坊:产出聚合、事件、边界

记录一次五小时订单域事件风暴工作坊:五类角色、时间盒、11 条命令、13 个事件、9 条策略与 6 个热点,明确哪些进 ADR、哪些留到卷二;随后把产出编码成 internal/eventstorm 包,用校验器检查命令归属、事件生产者、策略引用与孤立事件,并附真实 go test 输出。

工作坊不是画布,是一次有产出的会议

事件风暴最常见的失败,是把它开成“贴满一面墙的便签拍照会”:大家很兴奋,散会后没人知道哪条便签变成了代码。所以这次工作坊在开始前就定了三条规矩:只写已经发生的事实、每条便签只写一件事、上午不讨论技术方案。

参会角色与关注点如下。业务方不是来旁听的,他要在“什么算一笔成功订单”上做最终裁决;风控只在支付前介入,不参与库存讨论。

角色人数只回答的问题
业务1哪些事件对收入、履约承诺有影响
订单2订单状态允许怎么走、哪些规则必须原子
库存1预留粒度、释放时机、超卖边界
支付1成功/失败/超时分别意味着什么
风控1支付前要拦什么、拦截后订单去哪

时间盒:09:30–10:00 对齐订单全生命周期;10:00–11:30 事件倾倒;11:30–12:00 归类聚合;13:30–15:00 补命令与策略;15:00–16:00 收敛热点并分配 ADR。上午只允许问“然后发生了什么”,不允许问“这个接口怎么设计”。

四张表

命令表

命令是意图,可能被拒绝。命名用祈使句,每条命令必须声明它由哪个聚合处理、由谁触发。

命令处理聚合触发方说明
PlaceOrderOrderHTTP客户下单,支持幂等键
ReserveInventoryInventoryReservation策略按 SKU/仓预留
MarkInventoryReservedOrder策略全部明细预留成功后推进状态
ConfirmPaymentPayment支付网关回调或主动查询确认
MarkOrderPaidOrder策略支付确认后推进订单
CancelOrderOrderHTTP/策略客户取消或失败补偿
ReleaseInventoryInventoryReservation策略释放已预留库存
RefundPaymentPayment策略发起退款
FulfillOrderOrderHTTP仓库发货
ExpireOrderOrder定时器超时未支付关单
RequestRefundOrderHTTP客户申请退款

事件表

事件是已经发生的事实,统一用过去式。Observed 表示该事件只被投影、审计或外部系统消费,不再触发新命令。

事件生产聚合生产者命令Observed
OrderPlacedOrderPlaceOrder否
InventoryReservedInventoryReservationReserveInventory否
InventoryReservationFailedInventoryReservationReserveInventory否
PaymentConfirmedPaymentConfirmPayment否
PaymentDeclinedPaymentConfirmPayment否
OrderPaidOrderMarkOrderPaid是
OrderCancelledOrderCancelOrder否
InventoryReleasedInventoryReservationReleaseInventory是
PaymentRefundedPaymentRefundPayment是
OrderFulfilledOrderFulfillOrder是
OrderExpiredOrderExpireOrder否
RefundRequestedOrderRequestRefund否
OrderInventoryReservedOrderMarkInventoryReserved是

聚合表

聚合是一致性边界,不是“相关的表”。工作坊只圈出必须在一个事务里改完的最小集合。

聚合标识内部对象负责的不变式
Orderorder_idOrderItem、金额、状态总额等于明细之和;状态只经合法边迁移;同一订单最多支付一次
InventoryReservationreservation_idSKU、仓、数量、状态同一 SKU/仓的预留不能超过可售量
Paymentpayment_id金额、状态、provider_ref同一幂等键只产生一笔支付

策略表

策略是“当某事件发生,如果满足条件,就发出某命令”。它是工作坊里最容易被忽略、却最能暴露边界的一张表。

策略触发事件发出命令守卫条件
P1OrderPlacedReserveInventoryorder.status == CREATED
P2InventoryReservedMarkInventoryReserved全部明细预留成功
P3InventoryReservationFailedCancelOrderorder.status == CREATED
P4PaymentConfirmedMarkOrderPaidorder.status == INVENTORY_RESERVED
P5PaymentDeclinedCancelOrder、ReleaseInventory支付尝试已终态
P6OrderCancelledReleaseInventory取消前状态为 INVENTORY_RESERVED
P7OrderCancelledRefundPayment取消前状态为 PAID
P8OrderExpiredCancelOrder、ReleaseInventory预留 TTL 到期且未支付
P9RefundRequestedRefundPaymentorder.status == PAID

一次完整链路走查

正常路径:PlaceOrder → OrderPlaced → P1 → ReserveInventory → InventoryReserved → P2 → MarkInventoryReserved → OrderInventoryReserved;客户支付后 ConfirmPayment → PaymentConfirmed → P4 → MarkOrderPaid → OrderPaid;仓库发货 FulfillOrder → OrderFulfilled。

失败路径有三条。预留失败:InventoryReservationFailed → P3 → CancelOrder。支付被拒:PaymentDeclined → P5 → CancelOrder + ReleaseInventory。超时未付:定时器发 ExpireOrder → OrderExpired → P8 → CancelOrder + ReleaseInventory。

走查时发现一个真问题:P5 同时发 CancelOrder 和 ReleaseInventory,但 CancelOrder 成功后 P6 又会发一次 ReleaseInventory。释放必须幂等,否则同一预留会被释放两次。这个问题不靠代码评审发现,靠把策略表逐条走一遍。

热点与未决问题

热点工作坊结论去向
已支付订单取消,先退款还是先释放库存先退款,退款成功后再释放;退款失败保持预留并转人工ADR-003
支付回调超时,钱扣了但订单没收到成功不在同步链路猜;按 provider_ref 主动查询并对账卷二
预留 TTL 多长、由谁扫描卷一只定义 ExpireOrder 命令,TTL 配置化卷二
部分履约是否允许卷一按整单履约;拆单需要新的 Shipment 聚合卷二
风控拦截发生在下单前还是支付前支付前,以 PaymentDeclined 的 reason 区分ADR-004
事件 schema 如何演进卷一只做进程内事件,跨进程契约留到卷三卷三

ADR-003 的核心取舍是:退款是外部不可逆操作,释放库存是可逆的。先退款可以把“钱没退但货已卖”的风险关在门外,代价是退款处理期间库存被占用。对 arch-lab 这种低客单价、低库存周转的场景,这个代价可以接受;如果库存周转是瓶颈,结论要重开。

把工作坊产出变成可执行模型

四张表如果只留在白板上,下一次迭代就会漂移。把它们编码成 internal/eventstorm 包,并提供 Validate:每条命令必须有聚合,每个事件必须有生产者且聚合一致,每条策略引用的命令/事件必须存在,每个事件要么被策略消费、要么显式标记为 Observed,策略触发的命令必须至少被一条策略发出。

// file: internal/eventstorm/model.go
// tier: L1
// verify: go test ./internal/eventstorm/
package eventstorm

import "fmt"

type Trigger string

const (
	TriggerHTTP    Trigger = "HTTP"
	TriggerTimer   Trigger = "TIMER"
	TriggerGateway Trigger = "GATEWAY"
	TriggerPolicy  Trigger = "POLICY"
)

type Command struct {
	Name      string
	Aggregate string
	Triggers  []Trigger
}

type Event struct {
	Name      string
	Aggregate string
	Producer  string
	Observed  bool
}

type Policy struct {
	Name  string
	On    []string
	Emits []string
	Guard string
}

type Model struct {
	Aggregates []string
	Commands   []Command
	Events     []Event
	Policies   []Policy
}

// Validate 把工作坊的四张表变成一组可执行断言。
func (m Model) Validate() []string {
	var out []string
	aggregates := make(map[string]bool, len(m.Aggregates))
	commands := make(map[string]Command, len(m.Commands))
	events := make(map[string]Event, len(m.Events))
	consumed := make(map[string]bool)
	emitted := make(map[string]bool)

	for _, a := range m.Aggregates {
		aggregates[a] = true
	}
	for _, c := range m.Commands {
		if _, dup := commands[c.Name]; dup {
			out = append(out, "duplicate command "+c.Name)
		}
		commands[c.Name] = c
		if !aggregates[c.Aggregate] {
			out = append(out, fmt.Sprintf("command %s has unknown aggregate %s", c.Name, c.Aggregate))
		}
		if len(c.Triggers) == 0 {
			out = append(out, "command without trigger "+c.Name)
		}
	}
	for _, e := range m.Events {
		if _, dup := events[e.Name]; dup {
			out = append(out, "duplicate event "+e.Name)
		}
		events[e.Name] = e
		producer, ok := commands[e.Producer]
		if !ok {
			out = append(out, fmt.Sprintf("event %s has no producer command %s", e.Name, e.Producer))
			continue
		}
		if producer.Aggregate != e.Aggregate {
			out = append(out, fmt.Sprintf("event %s aggregate %s != producer aggregate %s", e.Name, e.Aggregate, producer.Aggregate))
		}
	}
	for _, p := range m.Policies {
		if len(p.On) == 0 || len(p.Emits) == 0 || p.Guard == "" {
			out = append(out, "incomplete policy "+p.Name)
		}
		for _, name := range p.On {
			if _, ok := events[name]; !ok {
				out = append(out, fmt.Sprintf("policy %s references unknown event %s", p.Name, name))
			}
			consumed[name] = true
		}
		for _, name := range p.Emits {
			if _, ok := commands[name]; !ok {
				out = append(out, fmt.Sprintf("policy %s emits unknown command %s", p.Name, name))
			}
			emitted[name] = true
		}
	}
	for _, e := range m.Events {
		if !e.Observed && !consumed[e.Name] {
			out = append(out, "orphan event "+e.Name)
		}
	}
	for _, c := range m.Commands {
		if hasPolicyTrigger(c.Triggers) && !emitted[c.Name] {
			out = append(out, "unreachable policy command "+c.Name)
		}
	}
	return out
}

func hasPolicyTrigger(triggers []Trigger) bool {
	for _, tr := range triggers {
		if tr == TriggerPolicy {
			return true
		}
	}
	return false
}

测试先构造工作坊的合法模型,再逐条变异。合法模型本身就是一份可执行的会议纪要:改动任何一条策略,都必须同时改测试。

// file: internal/eventstorm/model_test.go
// tier: L1
// verify: go test ./internal/eventstorm/ -v
package eventstorm

import (
	"strings"
	"testing"
)

func workshopModel() Model {
	return Model{
		Aggregates: []string{"Order", "InventoryReservation", "Payment"},
		Commands: []Command{
			{Name: "PlaceOrder", Aggregate: "Order", Triggers: []Trigger{TriggerHTTP}},
			{Name: "ReserveInventory", Aggregate: "InventoryReservation", Triggers: []Trigger{TriggerPolicy}},
			{Name: "MarkInventoryReserved", Aggregate: "Order", Triggers: []Trigger{TriggerPolicy}},
			{Name: "ConfirmPayment", Aggregate: "Payment", Triggers: []Trigger{TriggerGateway}},
			{Name: "MarkOrderPaid", Aggregate: "Order", Triggers: []Trigger{TriggerPolicy}},
			{Name: "CancelOrder", Aggregate: "Order", Triggers: []Trigger{TriggerHTTP, TriggerPolicy}},
			{Name: "ReleaseInventory", Aggregate: "InventoryReservation", Triggers: []Trigger{TriggerPolicy}},
			{Name: "RefundPayment", Aggregate: "Payment", Triggers: []Trigger{TriggerPolicy}},
			{Name: "FulfillOrder", Aggregate: "Order", Triggers: []Trigger{TriggerHTTP}},
			{Name: "ExpireOrder", Aggregate: "Order", Triggers: []Trigger{TriggerTimer}},
			{Name: "RequestRefund", Aggregate: "Order", Triggers: []Trigger{TriggerHTTP}},
		},
		Events: []Event{
			{Name: "OrderPlaced", Aggregate: "Order", Producer: "PlaceOrder"},
			{Name: "InventoryReserved", Aggregate: "InventoryReservation", Producer: "ReserveInventory"},
			{Name: "InventoryReservationFailed", Aggregate: "InventoryReservation", Producer: "ReserveInventory"},
			{Name: "PaymentConfirmed", Aggregate: "Payment", Producer: "ConfirmPayment"},
			{Name: "PaymentDeclined", Aggregate: "Payment", Producer: "ConfirmPayment"},
			{Name: "OrderPaid", Aggregate: "Order", Producer: "MarkOrderPaid", Observed: true},
			{Name: "OrderCancelled", Aggregate: "Order", Producer: "CancelOrder"},
			{Name: "InventoryReleased", Aggregate: "InventoryReservation", Producer: "ReleaseInventory", Observed: true},
			{Name: "PaymentRefunded", Aggregate: "Payment", Producer: "RefundPayment", Observed: true},
			{Name: "OrderFulfilled", Aggregate: "Order", Producer: "FulfillOrder", Observed: true},
			{Name: "OrderExpired", Aggregate: "Order", Producer: "ExpireOrder"},
			{Name: "RefundRequested", Aggregate: "Order", Producer: "RequestRefund"},
			{Name: "OrderInventoryReserved", Aggregate: "Order", Producer: "MarkInventoryReserved", Observed: true},
		},
		Policies: []Policy{
			{Name: "P1", On: []string{"OrderPlaced"}, Emits: []string{"ReserveInventory"}, Guard: "order.status == CREATED"},
			{Name: "P2", On: []string{"InventoryReserved"}, Emits: []string{"MarkInventoryReserved"}, Guard: "all lines allocated"},
			{Name: "P3", On: []string{"InventoryReservationFailed"}, Emits: []string{"CancelOrder"}, Guard: "order.status == CREATED"},
			{Name: "P4", On: []string{"PaymentConfirmed"}, Emits: []string{"MarkOrderPaid"}, Guard: "order.status == INVENTORY_RESERVED"},
			{Name: "P5", On: []string{"PaymentDeclined"}, Emits: []string{"CancelOrder", "ReleaseInventory"}, Guard: "payment attempt is terminal"},
			{Name: "P6", On: []string{"OrderCancelled"}, Emits: []string{"ReleaseInventory"}, Guard: "order was INVENTORY_RESERVED"},
			{Name: "P7", On: []string{"OrderCancelled"}, Emits: []string{"RefundPayment"}, Guard: "order was PAID"},
			{Name: "P8", On: []string{"OrderExpired"}, Emits: []string{"CancelOrder", "ReleaseInventory"}, Guard: "reservation TTL reached"},
			{Name: "P9", On: []string{"RefundRequested"}, Emits: []string{"RefundPayment"}, Guard: "order.status == PAID"},
		},
	}
}

func has(violations []string, fragment string) bool {
	for _, v := range violations {
		if strings.Contains(v, fragment) {
			return true
		}
	}
	return false
}

func TestWorkshopModelIsValid(t *testing.T) {
	if violations := workshopModel().Validate(); len(violations) != 0 {
		t.Fatalf("workshop model should be valid, got %v", violations)
	}
}

func TestValidateTable(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*Model)
		want   string
	}{
		{"command without aggregate", func(m *Model) {
			m.Commands = append(m.Commands, Command{Name: "ShipIt", Aggregate: "Shipment", Triggers: []Trigger{TriggerHTTP}})
		}, "unknown aggregate Shipment"},
		{"event without producer", func(m *Model) {
			m.Events = append(m.Events, Event{Name: "GhostEvent", Aggregate: "Order", Producer: "GhostCommand"})
		}, "no producer command GhostCommand"},
		{"event aggregate mismatch", func(m *Model) { m.Events[0].Aggregate = "Payment" }, "aggregate Payment != producer aggregate Order"},
		{"unknown event in policy", func(m *Model) {
			m.Policies = append(m.Policies, Policy{Name: "P10", On: []string{"GhostEvent"}, Emits: []string{"CancelOrder"}, Guard: "never"})
		}, "references unknown event GhostEvent"},
		{"unknown command in policy", func(m *Model) {
			m.Policies = append(m.Policies, Policy{Name: "P10", On: []string{"OrderPlaced"}, Emits: []string{"GhostCommand"}, Guard: "never"})
		}, "emits unknown command GhostCommand"},
		{"orphan event", func(m *Model) {
			m.Events = append(m.Events, Event{Name: "OrderArchived", Aggregate: "Order", Producer: "PlaceOrder"})
		}, "orphan event OrderArchived"},
		{"unreachable policy command", func(m *Model) {
			m.Commands = append(m.Commands, Command{Name: "RecalculateRisk", Aggregate: "Order", Triggers: []Trigger{TriggerPolicy}})
		}, "unreachable policy command RecalculateRisk"},
		{"policy without guard", func(m *Model) { m.Policies[0].Guard = "" }, "incomplete policy P1"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			m := workshopModel()
			tt.mutate(&m)
			if violations := m.Validate(); !has(violations, tt.want) {
				t.Fatalf("violations = %v, want %q", violations, tt.want)
			}
		})
	}
}

在 darwin/arm64 + Go 1.26.0 上实际运行:

# 输出节选:其余 7 个子用例均为 PASS
=== RUN   TestWorkshopModelIsValid
--- PASS: TestWorkshopModelIsValid (0.00s)
=== RUN   TestValidateTable
=== RUN   TestValidateTable/orphan_event
--- PASS: TestValidateTable (0.00s)
    --- PASS: TestValidateTable/orphan_event (0.00s)
PASS
ok  	archlab/internal/eventstorm	0.457s

事件命名与策略的边界

事件必须是过去式,因为它描述已经发生的事实;命令必须是祈使句,因为它描述尚未发生的意图。PaymentConfirmed 和 ConfirmPayment 只差一个时态,但在代码里一个是输入、一个是输出,混用会让幂等设计失去落点。策略不是领域服务:它只回答“什么事件触发什么命令”,不写业务计算;守卫条件里的状态判断应该调用聚合的方法,而不是在策略里复制一份状态表,否则工作坊产出的表会变成第二套状态机。

与站内文章的分工

站内的 消息驱动架构 已经讲了事件通知、事件携带状态转移、消息队列选型与幂等消费。本节不重复这些模式,只记录一次工作坊如何从业务语言走到可执行的四张表,以及这些表如何被 internal/eventstorm 校验。

常见错误与边界

第一,把事件写成命令。ReserveInventory 是命令,InventoryReserved 是事件;把两者都叫“库存消息”,消费者就分不清该重试还是该补偿。第二,只写正常路径。工作坊的大部分价值来自 PaymentDeclined、InventoryReservationFailed、OrderExpired 这些不愉快的事件;没有它们,策略表只是主流程的注释。

第三,聚合按数据库表圈。Order 和 OrderItem 可以是一个聚合,但 Payment 不行;聚合边界看事务与不变式,不看外键。

第四,策略没有守卫条件。没有守卫的 P6 会在 CREATED 取消时也发 ReleaseInventory,把“没预留”当成“已预留”。

第五,把热点留成待办。每个热点必须明确去向:进 ADR、进卷二、还是当场否决;否则下次工作坊会重新讨论一遍。

小结

事件风暴的产出不是一面墙的便签,而是四张能互相校验的表:11 条命令、13 个事件、3 个聚合、9 条策略。走查暴露了重复释放的幂等要求;热点表把退款顺序送进 ADR-003,把支付超时、预留 TTL、部分履约和事件契约留到卷二、卷三。internal/eventstorm 把这些表变成可执行断言,表驱动测试保证命令有聚合、事件有生产者、策略引用有效、没有孤立事件。下一节讨论这些聚合到底该多大,以及哪些不变式必须在一个事务里完成。

阅读导航:上一节:3.1 子域、限界上下文与团队边界 · 下一节:3.3 聚合、事务边界与一致性边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查