主题
建模方法:事件风暴、统一语言与规格模式
一句话:模型不是从表结构反推出来的,是从「已经发生的事实」里长出来的。事件风暴用一条事件时间轴把业务、领域和开发拉到同一张图前,统一语言保证说写一致,规格模式把规则变成可组合、可测试的对象。
为什么需要
两种常见的失败建模:
- 照表建类:先设计
orders、order_items,再反向生成实体。结果模型是表的镜像,业务规则只能塞进 Service。 - 文档翻译:先写一百页需求文档,开发逐条翻译成代码。业务说的「提单」变成
submitForm,同一概念两种叫法,边界从没被讨论过。
事件风暴(Event Storming)解决的是在写代码前,用 1–2 天产出一版可讨论的边界和聚合清单。它不保证模型一次正确,但能把分歧尽早暴露在墙上,而不是上线后暴露在故障里。
核心机制
图例:颜色是语法,不是装饰
事件风暴由 Alberto Brandolini 提出。通行约定如下,本文统一采用这套颜色:
| 颜色 | 元素 | 含义 | 例子 |
|---|---|---|---|
| 橙色 | 领域事件(Domain Event) | 已经发生的事实,用过去式 | 「订单已提交」 |
| 蓝色 | 命令(Command) | 某人或某系统发起的意图 | 「提交订单」 |
| 黄色 | 聚合(Aggregate) | 命令的落点、事件的产生者 | 订单聚合 |
| 绿色 | 读模型(Read Model) | 决策前需要看的数据 | 「待审核订单列表」 |
| 粉色 | 外部系统/角色(Actor) | 触发命令的人或系统 | 用户、支付网关 |
| 紫色 | 策略(Policy) | 「当……就……」的自动反应 | 「当订单超时未支付,就取消」 |
| 红色 | 热点(Hotspot) | 分歧、风险、待确认的问题 | 「库存不足怎么处理?」 |
颜色约定可以由团队自定,但必须固定下来并写在工作坊说明里。最怕的是同一面墙上橙色一会儿是命令、一会儿是事件,讨论就会失真。本文后续一律按上表。
从事件到边界:四步
1. 铺事实:参会者各自写橙色事件,按时间从左到右贴,不辩论
2. 补意图:每个事件前贴蓝色命令,粉色标出触发者
3. 圈聚合:把「命令 → 事件」往返最紧密的对象圈成黄色
4. 划边界:把变更原因相同、语言一致的聚合归入一个限界上下文;红色标风险为什么先事件后实体:业务专家能准确说出「订单提交后发生了什么」,但往往说不清「订单对象有哪些字段」。事件是业务语言,实体是设计产物;先有事实,后有模型。这也是它和「先建表」的根本区别。
产出三样东西:事件时间轴、聚合清单、限界上下文草图,外加一张红色风险清单。它们都是 01 篇上下文画布的输入。
统一语言:术语表 + 代码命名 + 评审
统一语言(Ubiquitous Language)不是一本字典,而是三处同时生效:
| 层 | 动作 | 可验证的产物 |
|---|---|---|
| 业务 | 每个上下文一份术语表,业务方签字 | 术语、定义、反例、对应代码元素 |
| 代码 | 类名、方法名、事件名用业务术语 | OrderPaid,不是 OrderStatusNotifyDTO |
| 评审 | 术语漂移进 Code Review 检查项 | 领域层出现 DTO 命名就打回 |
漂移的三个信号:业务说「提单」而代码叫 submitForm;同一概念出现两种叫法;传输对象命名混进领域层。三者任一出现,先修术语,再谈模型。
规格模式:把规则变成可组合对象
一段规则散在 if 里,既没有名字,也无法单独测试:
java
if (user.getLevel() == VIP && user.getBalance().compareTo(threshold) > 0 && !user.isBlacklisted()) { ... }规格模式(Specification)把规则封装成对象,用 and/or/not 组合,并让「可授信客户」这个业务词直接对应一个类。
实战场景
一、把事件风暴画成了表结构草稿
现象:花两天做了事件风暴,产出直接进数据库设计:每个命令对应一张表、每个“读模型”对应一个视图。三个月后一个不大需求(订单支持部分改期)改动 6 张表、4 个服务,评审时没人能说出“改期”在模型里是什么。
排查:产出物是否回答了“边界”而不是“存储”。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 事实轴的颗粒度 | 便签是否是“已发生的事实”(OrderRescheduled),还是动作/界面步骤(“点击提交”) | 写成 UI 步骤 → 只是在复述流程 |
| 聚类依据 | 分组时是否按“变更原因”聚类 | 按表/按模块分组 → 边界没找到 |
| 产物形态 | 是否存在上下文画布与映射表 | 只有 ER 图 → 输出停留在数据层 |
| 命令与事件的对应 | 每个命令是否能说出它想造成的事实 | 说不清 → 命令是从接口反推的 |
| 聚不出簇的处理 | 是否有“先不写代码”的记录 | 强行落库等于把模糊固化 |
模型不是从表结构反推出来的,是从已经发生的事实里长出来的;聚不出簇说明边界还没找到。
对症做法:
text
重做一次四步(约 2 小时/条业务流):
1. 选一条完整业务流,只列 10–15 个“已发生的事实”,禁止写界面动作与表名;
2. 按“变更原因”聚类:同一簇内的事实总是因同一个业务决定而变化;
3. 每簇填一页上下文画布(业务目的、关键术语、核心聚合、对外命令/事件、不做什么、数据归属);
4. 相邻画布填映射表(关系、契约形式、翻译位置、失败策略)。
“改期”应先作为一个事实与它自己的规则出现,而不是 orders 表上的三个字段。java
// 事实落成代码后,模型有自己的动词
public class Order {
public void rescheduleLines(List<LineChange> changes, Reason reason) { ... } // 部分改期
}
// 表结构只是 Order 的持久化细节,可以由 Repository 适配器决定复盘要点:判据是“新需求改动面”:一次业务变更应该只落在一个上下文的聚合与它的事件上;改动跨 4 个以上服务通常说明边界画错了,而不是需求特殊。产出物要进仓库跟代码一起评审,评审看画布与映射表,不看 ER 图。
二、cancel / void / close 三个词指同一件事
现象:客诉处理时发现“订单取消”有三套实现:客服后台调 closeOrder,履约侧调 cancelOrder,结算侧用 voidOrder;三者的语义其实略有差别(是否可退款、是否回滚库存),但没人能说清差别是设计还是历史包袱。一次“取消后仍能退款”的资金问题追到三个方法的行为不一致。
排查:语言漂移会在代码形态上留下痕迹。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 同义词数量 | grep -Rin "cancel|void|close" --include=*.java 统计方法名 | 同一概念多个动词 → 术语表失效 |
| 术语表是否在仓库 | 是否有可引用的 ubiquitous-language.md 并被评审引用 | 只在 wiki/口头 → 无法约束新代码 |
| 业务与代码是否同词 | 需求文档说“关闭”,代码是否有 close | 说写不一致 → 翻译成本被转移到实现期 |
| 状态字段命名 | status 取值是否与术语一致(CANCELLED vs CLOSED) | 状态与动词分叉会让事件语义模糊 |
| 跨上下文是否同词不同义 | “订单”在结算与履约上下文里的定义 | 同词不同义要显式标注,属于不同 BC |
对症做法:
text
术语表按“一词一义”维护,进仓库、随代码评审:
| 术语(中文) | 代码命名 | 定义 | 属于哪个上下文 | 不是什么 |
| 订单取消 | OrderCancelled / cancel() | 用户或客服使订单不再履约,库存回滚,已付款进入退款流程 | 订单上下文 | 不是“订单关闭”(履约完成后归档) |
| 订单关闭 | OrderClosed | 履约完结后的归档动作,不触发退款 | 订单上下文 | 不是取消 |java
// 1. 收敛入口:三个旧方法保留一个真正的领域行为,其余标记废弃并转调
@Deprecated(since = "2.6", forRemoval = true) // 迁移说明写“用 cancel()”,保留一个 minor 周期
public void closeOrder(OrderId id) { order.cancel(CloseReasonCompat.archived(id)); }
// 2. 事件名与命令名保持同一动词,状态取值与事件同源
public record OrderCancelled(OrderId id, CancelReason reason, Money refundable) {}text
3. 评审时把“这个词在术语表里叫什么”作为固定问句;
新代码出现同义词由静态检查拦截(同一概念禁止新增别名方法)。复盘要点:统一语言的价值在“说写一致”——业务说取消,代码里就是 cancel,事件就是 OrderCancelled。判据可量化:同一概念的动词别名数应为 1;术语表覆盖的关键词在代码里的命中率应达 100%。同词不同义(不同上下文里的“订单”)不算漂移,但必须在画布里写明。
三、促销规则堆在 if/else 里,改一条要回归全部
现象:大促前加一条“会员 + 满 300 + 周末”的组合规则,开发在 PromotionService 里加了 40 行嵌套 if。上线后老规则误伤:非会员在周末也被减价,涉及订单 900 余笔(经验值,需校准),回滚整个服务。排查发现该文件有 11 个测试用例,全部是端到端场景,没有一条针对规则本身。
排查:规则不可组合,就一定不可测试。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 规则表达位置 | if 判断是散在服务方法里,还是独立的规格对象 | 散落 → 无法组合、无法单测 |
| 条件可命名性 | 能否用业务语言命名每条规则 | 命名不出来说明概念没被建模 |
| 组合方式 | “与/或/非”是否靠嵌套布尔表达式实现 | 嵌套越深,新增规则越易破坏旧规则 |
| 测试颗粒度 | 是否存在“单条规则 + 组合”的单元用例 | 只有端到端 → 回归成本线性上升 |
| 数据依赖 | 判断是否直接查库(userDao.isVip(...)) | 规则与基础设施耦合,难替换数据源 |
对症做法:
java
// 规格模式:规则成为可组合、可测试的对象
public interface Specification<T> {
boolean isSatisfiedBy(T candidate);
default Specification<T> and(Specification<T> other) {
return candidate -> isSatisfiedBy(candidate) && other.isSatisfiedBy(candidate);
}
default Specification<T> or(Specification<T> other) { return c -> isSatisfiedBy(c) || other.isSatisfiedBy(c); }
}
public record MemberSpec(CustomerRepository customers) implements Specification<Cart> {
public boolean isSatisfiedBy(Cart cart) { return customers.isMember(cart.customerId()); }
}
public record MinAmountSpec(Money threshold) implements Specification<Cart> {
public boolean isSatisfiedBy(Cart cart) { return cart.total().gte(threshold); }
}
public record WeekendSpec(Clock clock) implements Specification<Cart> {
public boolean isSatisfiedBy(Cart cart) {
DayOfWeek d = LocalDate.now(clock).getDayOfWeek();
return d == DayOfWeek.SATURDAY || d == DayOfWeek.SUNDAY;
}
}
// 组合规则是一行声明,读起来就是业务语言
Specification<Cart> weekendMemberDeal =
new MemberSpec(customers).and(new MinAmountSpec(money("300"))).and(new WeekendSpec(clock));java
@Test void nonMemberNeverSatisfies() { // 被误伤的那条边界,直接写成用例
Cart cart = cartFixture.customerIsNotMember().total("350").onSaturday();
assertThat(weekendMemberDeal.isSatisfiedBy(cart)).isFalse();
}
@Test void rulesComposeExplicitly() {
assertThat(new MinAmountSpec(money("300")).and(new MemberSpec(repo)).isSatisfiedBy(cart)).isTrue();
}text
落地要点:
1. 每条规则一个类,构造参数就是它需要的数据(时钟、汇率、仓库),可注入可替换;
2. 规则组合放在应用层的策略表里,配置化下发,而不是写死在 if 分支;
3. 判断结果与原因一起返回,便于对账与客服解释(规格名即原因)。复盘要点:判据是“新增一条规则需要改动几个地方”:正确做法是新增一个规格类 + 一行组合,而不是修改既有分支。回归成本也要量化:规则单元测试的覆盖率应覆盖每条规则的边界值,端到端用例只验证组合行为。规格模式让规则可组合、可测试,这正是“把规则放进模型”的收益。
最小上手
工作坊:90 分钟最小流程
- 材料:橙色/蓝色/黄色/绿色/粉色/紫色/红色便签各一叠,长墙或 Miro 白板,计时器。
- 0–15 分钟:讲清图例和范围(只讨论一条业务流),所有人静默写事件。
- 15–35 分钟:按时间贴事件,合并重复项,有分歧的贴红色,不在现场解决。
- 35–55 分钟:补命令和触发者,圈聚合。
- 55–75 分钟:按「变更原因」聚类成上下文,画上下文之间的箭头。
- 75–90 分钟:逐条过红色热点,指定 owner 和验证方式,形成待办。
验收不是「贴满了」,而是能指着墙说出:这条业务流产生了哪些事件、每个事件由哪个聚合产生、哪些聚合属于同一个上下文、哪些问题还没答案。
规格模式的可运行实现
java
@FunctionalInterface
public interface Specification<T> {
boolean isSatisfiedBy(T candidate);
default Specification<T> and(Specification<T> other) {
return c -> this.isSatisfiedBy(c) && other.isSatisfiedBy(c);
}
default Specification<T> or(Specification<T> other) {
return c -> this.isSatisfiedBy(c) || other.isSatisfiedBy(c);
}
}
public record CreditRule(Money minBalance, Set<Level> allowedLevels) implements Specification<Customer> {
public boolean isSatisfiedBy(Customer c) {
return c.balance().amount().compareTo(minBalance.amount()) >= 0
&& allowedLevels.contains(c.level())
&& !c.blacklisted();
}
}java
@Test
void vip_with_enough_balance_is_eligible() {
Specification<Customer> eligible = new CreditRule(Money.cny(10_000), Set.of(Level.VIP));
assertTrue(eligible.isSatisfiedBy(new Customer(Level.VIP, Money.cny(20_000), false)));
assertFalse(eligible.isSatisfiedBy(new Customer(Level.VIP, Money.cny(5_000), false)));
}运行:mvn -q test -Dtest=CreditRuleTest。规则有了名字,测试可以直接对着业务词写,而不是对着一个巨大的 Service 方法写。
生产动作
- 工作坊的时间盒:每个阶段严格计时。事件贴到 200 张还不停,通常是在讨论方案而不是事实,先收束到一条业务流。
- 产物进仓库:事件时间轴拍照不够,至少把聚合清单、上下文草图、红色热点转成 Markdown 放进
docs/modeling/,与 01 篇的画布互链。 - 术语审计:
rg -n "SubmitForm|OrderStatusNotifyDTO|OrderDTO" src/main/java/**/domain。领域层出现传输对象命名,按 04 篇的依赖规则处理。 - 规格复用检查:
rg -n "isSatisfiedBy" src/main/java看同一业务规则是否在多处重复实现;重复即说明规则还没有唯一归属。 - 排障:工作坊开完没有结论 → 回看红色热点是否都被记下并指派 owner → 若热点全是「以后再说」,说明范围太大,切成两条业务流重开。
- 排障:模型和代码对不上 → 拿术语表逐项对照领域层类名 → 对不上的是术语漂移,不是代码 bug;先改命名或改术语表,再改逻辑。
边界
- 纯 CRUD、报表系统:事件很少、规则很薄,事件风暴的产出抵不上一天的成本。
- 需求已经非常清晰的小改动:直接写测试和代码更快,不要为了仪式感开工作坊。
- 事件风暴不能替代领域专家:它只是把专家脑子里的事实外化;没有专家参与,贴出来的只是开发的猜想。
- 规格模式不是规则引擎:规则数量少、变化慢时,几个方法就够;把它做成可配置 DSL,往往先得到一套难调试的间接层。
讲清楚
建模方法的核心是「先事实、后模型」。事件风暴用颜色区分语法:橙色事件、蓝色命令、黄色聚合、绿色读模型,粉色标外部角色,紫色标策略,红色标风险;团队可以自定颜色,但必须固定并写清楚。流程是先静默铺事件,再补命令、圈聚合、划上下文,产出事件时间轴、聚合清单和风险清单。统一语言靠术语表、代码命名和评审三处同时落地。规则复杂到需要组合和复用时,用规格模式把规则变成有名字、可测试的对象。这套方法的价值不是画得好看,而是让边界分歧在写代码前暴露。
初稿 2026-10 | 最后修订 2026-10