写这个系列的时候,收到过一条很典型的评论:"DDD 是 Java 生态的东西吧?Go 要是写成这样会被笑,Python 根本没有真正的封装------你们这些'充血模型'换个语言是不是就得散架?"
这话问到了点子上。理论争论没意思,我们直接把同一个领域模型用八种语言各写了一遍:Java、Go、Python、Node、PHP、Rust、MoonBit、C#。这篇把八个栈的聚合根并排放给你看------哪些地方变了,变了多少;哪些地方一个字都不能变。看懂这张对照表,"充血模型是不是特定语言的特产"这个问题就有答案了。
一、签名墙:同一个命令,八种写法

先看最硬的证据。completeTask(完成任务)是整个引擎最高频的领域命令,八个栈的签名并排贴出来:
| 语言 | 签名(现行源码实录) | 一眼可见的差异 |
|---|---|---|
| Java | void completeTask(Long taskId, String operator, FlowData args) |
内部找任务,时间内部取 |
| Go | func (p *ProcessInstance) CompleteTask(task *ProcessTask, operator string, now time.Time) |
指针接收者,任务和时间都由引擎传入 |
| Python | def complete_task(self, task, operator, vars_, now) -> None |
snake_case,类型注解点到为止 |
| Node | completeTask(task: ProcessTask, operator: string, vars, now: Date): void |
TS 签名即文档 |
| PHP | public function completeTask(string $taskId, string $operator, ?FlowData $args): void |
和 Java 几乎同款 |
| Rust | pub fn complete_task(&mut self, task_id: i64, ...) -> Result<(), String> |
无异常,Result + ? 传播 |
| MoonBit | fn ProcessInstance::complete_task(...) -> Unit raise @error.JeeflowError |
错误写进签名 |
| C# | public void CompleteTask(long taskId, string? op, FlowData? args, IClock? clock = null) |
可空标注+时间抽象 |
八个签名长得确实不一样。但如果你把它们读"通"了,会发现每一行都在说同一句话:完成任务=任务状态转换+办理人落位+表单变量沉淀为流程变量,一次做完 。Java 版的 f_ 前缀提取写在 completeTask 里,MoonBit 版也写在 complete_task 里------连这段逻辑的位置都一样。
二、三条变形轴:语言吃什么,模型就长成什么样

把八个签名的差异归纳一下,其实只有三条轴。模型向每种语言的地形低头------但只低这三处:
轴① 错误处理:异常还是返回值。 Java/Go/Python/Node/PHP/C# 走异常路线;Rust 没有异常,complete_task 返回 Result<(), String>,调用链上的 ? 负责传播;MoonBit 走得最远------它把 raise @error.JeeflowError 直接写进函数签名,"这个命令会失败"成了类型系统的一部分。守卫的内容 在八栈一字不差,守卫的出口跟着各语言自治约定走:谁也不为领域模型去改造错误系统,也不为错误系统阉割领域模型。
轴② 时间从哪来:now 的三种活法。 Go/Python/Node 把 now 作为参数显式传入(now time.Time / now: Date),C# 注入 IClock 抽象(默认真实时钟),Java/PHP 在方法内部取当前时间。三种姿势一个目标:测试要确定性时间。同一张请假单在 2026-09-24 10:00 提交,无论在哪个栈,重放测试时拿到的都该是同一时刻------为此有的语言改签名,有的语言造抽象。
轴③ 任务入口:给 id 还是给实体。 Java/PHP/Rust/MoonBit/C# 的 completeTask 收 taskId,聚合在内部的任务集合里找(Java 的 findDoingTask,找不到直接抛"不在聚合根中");Go/Python/Node 则由引擎先把任务找好、校验好,再把实体交给聚合命令。两种风格的分界线后面是同一个不变量:不在聚合内的任务,一律办不了------找到的姿势不同,拒绝的态度相同。
除了这三条轴,任何"入乡随俗"的差异化发挥一律打回:命令名可以换拼写风格,不允许换语义;数据结构可以换容器,不允许换归属。规则不许有第二份。
三、三个不变:八栈同构的锚点

长得再不一样,八个栈靠三个东西锚在同一个语义上:
不变① 命令面=白名单。 complete、finish、reject、withdraw、pending、resume、interrupt、abandon------这些动词在八个栈里一一对得上,没有一个栈提供 setState 通用入口。第三篇讲的"非法迁移不可表达",在八种类型系统里各自成立了一次。
不变② 守卫与裁决是同一套规则。 "进行中才能办、参与者才能办、系统代执行与超管放行"------八栈都有。值得诚实交代的是层次可以有差异 :Java 把守卫写在子实体命令里(ProcessTask.finish 三连),Go 把对应检查放在引擎入口层("task not doing"、isAllowed,注释同样标注着代执行放行的语义)。规则文本零分歧,驻扎层次随实现习惯------这是八栈同构的"同"里允许的"异"。
不变③ 状态全集与语义。 实例 7 态、任务 6 态,数字与含义一一对应;每个流程的第一个任务节点办理人固定解析为发起人(applicant 约定,申请节点 assignee="applicant")。同一份流程 JSON,八个栈跑出同一个结果------这是多语言引擎的立身之本,也是"跨语言对齐"四个字的验收标准。
同构最有趣的证据藏在注释里。撤回语义的一条决议注释(withdraw 用已撤回(30)、与拒绝区分,issues/53 E25)在 Go、Python、Node 三个栈里原文复制;MoonBit 版的聚合代码里甚至留着一行自述:"rust 语义:任务不在聚合内报错"------实现者把对齐的源头都标了出来。规则只有一份,它写在规范里,八个栈各自认领。
四、为什么这件事证明的是 DDD 本身
八栈移植做下来,最大的收获反而是想通了一件事:充血模型的成分,没有一样是语言特性。
拆开看这套模型用到的全部构造:一个类或结构体、若干方法、一个任务集合、一个变量字典。封装严格度、类型系统强弱、错误处理风格------八种语言在这几条轴上分布得有多开,这套模型就原样活了多开。所谓"充血",从来不是"把代码写得像 Java",而是把规则放回它住的地方:状态机住进实体,一致性住进聚合根,错误住进语言自己的错误机制。
反过来讲,如果一个"领域模型"换个语言就散架------守卫跑到了调用方手里、状态字符串满天飞、变量合并要靠两边约定------那它从一开始就不是领域模型,只是一个带方法的数据袋。语言会放大模型的品质,但制造不了品质。
五、换语言写领域模型:三条带走
如果你也面对"同一套领域规则、多种技术栈"的局面,这轮移植沉淀了三条可直接带走的经验:
先翻规则,不翻代码。 移植第一栈时最诱惑的做法是对着 Java 源码逐行翻译------结果是带着 Java 的习惯写出四不像。第二栈起换了顺序:先抄规范(状态全集、命令面、错误码),再让本地工程师用母语习惯实现,对齐速度反而快了一倍。
错误处理跟语言走,别发明兼容层。 不要在 Rust 里模拟异常,也不要在 Go 里造 panic 体系。守卫内容照抄,出口就地落进该语言的自治约定------这是三条变形轴里最不能省事的一条。
时间注入早做。 now 进签名或时钟抽象,第一版就定下来。事后补的话,八栈里每一个测试都要跟着改。
结尾
五篇讲完了。从两笔行数账(①),到聚合边界(②)、状态收口(③)、事务分层(④),到今天的八栈同构(⑤)------其实从头到尾只在论证一件事:把流程知识放回领域,它就能在任何语言里活下去。
引擎现在跑在八个语言栈、十几个框架上,同一份流程定义在哪个栈里都走出同一个结果。它不是某个语言的最佳实践,它是一套不挑语言的规则。八套源码全部开源,本文签名墙上的每一行,你都可以在自己机器上敲出来。
参考资料
- mldong 官网(框架 / 在线演示):www.mldong.com/
- jeeflow 文档站(规范 / 快速上手 / 八语言 demo):jeeflow-doc.mldong.com/
- jeeflow 演示站:jeeflow-demo.mldong.com/
- 系列往篇:《同一个审批流引擎,我写了两次》《聚合边界:为什么 ProcessTask 没有自己的 Repository》《引擎里没有 setStatus》《事务不归引擎管》(DDD 系列 ①②③④,掘金 jeeflow系列 专栏可查)