Wow 获 KaiCode’26 Excellent Award:DDD/CQRS 如何落到可测试代码?

感谢 KaiCode'26 对 Wow 的认可,也感谢一路参与 Wow 的贡献者和使用者。

这篇文章想回到 Wow 本身,回答一个长期困扰 DDD 实践者的问题:

一个主打 DDD、CQRS 和 Event Sourcing 的框架,怎样证明自己不是"架构概念展览馆"?

我的答案是:看它能否把复杂性从业务代码中拿走,同时又不把复杂性藏进一个无法测试、无法观察的黑盒。

本文不打算再列一遍功能清单,而是用 Wow 仓库里真实的购物车代码,拆开它的核心理念:模型即服务(Domain Model as a Service)

一、DDD 最大的问题,往往不是不会画图

很多团队第一次实践 DDD,最后得到的代码结构大致是:

text 复制代码
Controller
  -> ApplicationService
    -> DomainService
      -> Repository
        -> ORM

目录变多了,类变多了,业务规则却依然散落在参数校验、Service 条件分支和数据库更新语句里。

这不是分层本身有问题,而是我们经常把"业务能力"实现成一次数据库状态修改:

sql 复制代码
update cart_item
set quantity = quantity + 1
where cart_id = ? and product_id = ?;

这条 SQL 能告诉我们现在的数量,却没有回答三个更重要的问题:

  1. 谁发起了什么意图?
  2. 当时为什么允许这次修改?
  3. 这次修改产生了什么业务事实?

在 Wow 中,这三个问题分别对应 Command、聚合规则和 Domain Event

二、先看一段真实的领域模型

下面的代码来自 Wow 当前仓库的购物车示例(正文略去了与主题无关的部分):

kotlin 复制代码
@StaticTenantId
@AggregateRoot
@AggregateRoute(owner = AggregateRoute.Owner.AGGREGATE_ID)
class Cart(private val state: CartState) {

    @OnCommand(returns = [CartItemAdded::class, CartQuantityChanged::class])
    fun onCommand(command: AddCartItem): Any {
        require(state.items.size < MAX_CART_ITEM_SIZE) {
            "购物车最多只能添加[$MAX_CART_ITEM_SIZE]个商品."
        }

        state.items.firstOrNull { it.productId == command.productId }
            ?.let {
                return CartQuantityChanged(
                    changed = it.copy(quantity = it.quantity + command.quantity)
                )
            }

        return CartItemAdded(
            added = CartItem(
                productId = command.productId,
                quantity = command.quantity
            )
        )
    }
}

这段代码只做两件事:

  • 根据当前状态校验业务规则;
  • 返回一个表示"已经发生什么"的事件。

它没有注入 Repository,没有打开事务,也没有直接修改 CartState。如果商品已存在,产生 CartQuantityChanged;否则产生 CartItemAdded

状态变化在另一个确定性的入口完成:

kotlin 复制代码
class CartState(val id: String) {
    var items: List<CartItem> = listOf()
        private set

    @OnSourcing
    fun onCartItemAdded(event: CartItemAdded) {
        items = items + event.added
    }

    @OnSourcing
    fun onCartQuantityChanged(event: CartQuantityChanged) {
        items = items.map {
            if (it.productId == event.changed.productId) event.changed else it
        }
    }
}

onCommand 负责决策,onSourcing 负责把已经发生的事实应用到状态。这条边界非常重要:

  • 与状态有关的业务决策放在命令处理阶段;
  • onSourcing 只应用事件,不做业务校验、外部调用或其他副作用;
  • 在同一模型版本与兼容策略下,同一串事件应确定性地得到相同状态。

这才是 Event Sourcing 的可维护性基础,而不是简单地"把数据库表换成事件表"。

三、Wow 的核心理念:模型即服务

有了上面的聚合模型之后,传统项目里仍然要手写不少胶水:Controller、路由、参数校验、命令分发、事件持久化、OpenAPI 描述......

Wow 的做法是让 wow-compiler 在编译期扫描 @AggregateRoot@OnCommand@OnEvent 等声明,生成命令与处理器映射、事件处理器元数据和 WebFlux/OpenAPI 路由所需的元数据。

换句话说,Wow 不是让你在领域模型旁边再搭建一套"服务层",而是把模型直接物化为服务能力:

  1. 模型定义能力:Command 表达意图,聚合规则负责决策,Domain Event 记录事实;
  2. 模型生成入口:编译期元数据驱动 WebFlux 路由与 OpenAPI,无需重复编写 Controller;
  3. 模型驱动状态:事件被持久化、发布并用于重建聚合状态;
  4. 模型可以验证:Given → When → Expect 直接测试业务决策、事件和最终状态。

运行时的主链路可以简化为:

text 复制代码
HTTP 请求
  -> 自动注册的 WebFlux 路由
  -> CommandGateway
  -> 加载快照与历史事件
  -> 聚合根处理 Command
  -> 追加 Domain Event
  -> 发布事件
  -> Projection / Saga / EventHandler

这并不意味着框架"消灭了复杂性"。持久化、幂等、路由、并发控制、事件发布和故障恢复仍然存在,只是它们被放回了框架和基础设施层,不再要求每个业务功能重复实现一遍。

这就是 Wow 所说的"模型即服务":

模型不是藏在 Service 和 Repository 后面的内部对象;模型本身就是业务能力的定义,框架负责把它变成可调用、可持久化、可观察、可测试的服务。

四、CQRS 最尴尬的"等一秒再刷新",应该由协议解决

CQRS 读写分离后,一个常见问题是:命令已经成功,但投影还没更新。很多系统最后写出类似代码:

javascript 复制代码
await submitCommand();
await sleep(1000);
await refreshQuery();

这段代码既不可靠,也不可观测。投影 100 毫秒完成时白等,1.2 秒完成时仍然读到旧数据。

Wow 把命令处理过程定义为一组可等待阶段:

  • SENT:命令已发送;
  • PROCESSED:聚合已处理并产生结果;
  • SNAPSHOT:快照已生成;
  • PROJECTED:符合等待条件的目标投影已完成;
  • EVENT_HANDLED:指定事件处理器已完成;
  • SAGA_HANDLED:指定 Saga 已完成。

HTTP 客户端可以通过请求头表达自己真正需要的一致性边界。比如,等待示例服务中的 OrderProjector 完成:

http 复制代码
Command-Wait-Stage: PROJECTED
Command-Wait-Context: example-service
Command-Wait-Processor: OrderProjector

只指定 PROJECTED 时,Wow 默认匹配当前上下文中符合条件的投影信号;存在多个投影时,可以继续通过 Command-Wait-ContextCommand-Wait-ProcessorCommand-Wait-Function 精确指定等待目标。

客户端不是猜一个延迟,而是在等待一个明确的业务处理信号。对于只关心吞吐量的写入,可以等待 SENT;对于提交后立即查询的交互,可以等待目标 PROJECTED 信号。

这是一个很小的 API 设计,却直接改善了 CQRS 的使用体验。

五、架构好不好,测试最诚实

如果一个领域模型必须启动 Spring、Kafka、MongoDB 才能验证业务规则,那么它的边界大概率还不够干净。

Wow 的测试 DSL 使用 Given → When → Expect 描述聚合行为:

kotlin 复制代码
class CartSpec : AggregateSpec<Cart, CartState>({
    on {
        givenOwnerId(generateGlobalId())

        whenCommand(
            AddCartItem(productId = "productId", quantity = 1)
        ) {
            expectNoError()
            expectEventType(CartItemAdded::class)
            expectState {
                items.assert().hasSize(1)
            }
        }
    }
})

这个测试同时约束了三件事:命令能否被接受、产生什么事件、事件应用后的状态是什么。

在撰写本文时,我在当前 8.9.1 代码上执行了:

bash 复制代码
./gradlew :example-domain:test \
  --tests "me.ahoo.wow.example.domain.cart.CartSpec"

结果为 BUILD SUCCESSFUL。这不是为了在文章里摆一条绿色日志,而是强调:示例代码必须和项目当前行为一起演进

对框架项目来说,可重复的测试、静态分析和持续集成,比"又支持了一个中间件"更难长期坚持,也更能说明"模型即服务"不是一个只存在于架构图里的口号。

六、什么项目适合 Wow,什么项目不适合?

Wow 更适合这些场景:

  • 业务规则复杂,状态流转需要被明确建模;
  • 审计、追溯、事件回放是核心需求;
  • 写模型和查询模型有不同的伸缩方式;
  • 存在跨聚合、跨服务的 Saga 与最终一致性流程;
  • 团队愿意用 Command / Event 语言讨论业务。

它不一定适合:

  • 只有少量表单和后台 CRUD 的系统;
  • 团队并不需要事件历史,却愿意为 Event Sourcing 支付全部认知成本;
  • 业务边界尚未形成,只想先用框架"替自己完成建模"。

框架可以降低 DDD 与 Event Sourcing 的工程成本,但不能替团队识别限界上下文,也不能替产品负责人说清业务规则。

写在最后

KaiCode'26 的认可是一份鼓励,但 Wow 真正想长期坚持的仍然是"模型即服务"。判断这件事有没有做到,可以看几个问题:

  • 领域模型是否仍然是代码的中心?
  • 编译期自动化有没有侵蚀可理解性?
  • 测试能否覆盖真实业务行为?
  • 一致性、失败与延迟是否对调用方可见?
  • 文档和协作流程是否配得上代码质量?

奖项会过去,这些问题不会。

如果你正在评估 DDD、CQRS 或 Event Sourcing,不妨先从购物车这样的小聚合开始:写一个 Command,产生一个 Event,用 Given → When → Expect 验证它。等模型能够清晰表达业务之后,再讨论 Kafka、MongoDB、水平扩容和微服务。

这通常比先画一张宏大的架构图更接近正确的起点。

你的项目是怎样处理 CQRS 写后读一致性的?是固定延迟、轮询、事件通知,还是其他方案?欢迎在评论区分享实践和踩过的坑。


相关链接

说明:本文由 AI 辅助整理,技术事实均基于 Wow 当前仓库、测试、项目文档与 KaiCode 官方结果页核验。