跳到正文

建模方法:事件风暴、统一语言与规格模式 ​

一句话:模型不是从表结构反推出来的,是从「已经发生的事实」里长出来的。事件风暴用一条事件时间轴把业务、领域和开发拉到同一张图前,统一语言保证说写一致,规格模式把规则变成可组合、可测试的对象。

为什么需要 ​

两种常见的失败建模:

  • 照表建类:先设计 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

把一门技术讲透到既懂也会用