用 DDD 设计工作流引擎:聚合根与充血模型

系列定位 :jeeflow 系列第 5 篇(第二季「核心设计」第 2 篇) 平台 :掘金(代码密度高,原理讲透) 素材版本 :引擎 v1.8.15,源码取自 jeeflow-java 前置阅读第 3 篇 · 状态机与 submitType


一、一个反直觉的问题

工作流引擎 98KB,零框架依赖,五语言同构------这些是上一篇文章讲过的"外在特征"。

但真正决定引擎能不能扛住复杂业务的,是内部怎么组织代码。

先看一个反直觉的事实:jeeflow 的核心引擎类 JeeflowEngineImpl 只有 300 行 ,其中一大半是节点路由逻辑(decision/fork/join)。真正的业务规则------状态怎么变、谁有权审批、驳回时哪些任务要废弃------一行都不在引擎里。

那它们在哪?

在两个类里:ProcessInstance(375 行)和 ProcessTask(181 行)。

这就是本文要讲的:用 DDD 的聚合根 + 充血模型,把工作流引擎的规则从"上帝服务类"里解放出来。


二、贫血模型 vs 充血模型

先定义两个概念,不扯理论,只看代码。

贫血模型:数据袋子 + 上帝服务

大多数自研工作流引擎长这样:

java 复制代码
// 贫血实体:只有 getter/setter,没有行为
public class ProcessInstance {
    private Long instanceId;
    private Integer state;
    private List<ProcessTask> tasks;
    // 全是 getter/setter,没有业务方法
}

// 上帝服务类:堆所有规则,5000 行起步
public class ProcessService {
    public void completeTask(Long taskId, String operator) {
        // 1. 查任务
        // 2. 校验状态
        // 3. 校验权限
        // 4. 改任务状态
        // 5. 合并变量
        // 6. 判断是否所有任务完成
        // 7. 改实例状态
        // 8. 创建下一个任务
        // 9. 持久化
        // ... 还有 reject/withdraw/interrupt/pending/resume
        // 总共 2000 行,全在这里
    }
}

问题在哪?

  1. 规则散落:状态校验、权限判断、级联逻辑全在 Service 里,实体自己不知道"完成"意味着什么
  2. 不可复用:Go/Python/Node 重写时,这套 2000 行的规则要再写一遍
  3. 难以测试:测一个"完成任务"要 mock 整个 Service 依赖链

充血模型:规则跟着数据走

jeeflow 的做法:

java 复制代码
// 充血实体:自己知道怎么"完成"
public class ProcessTask {
    public void finish(String operator, FlowData args) {
        // 前置校验:状态 + 权限
        if (!ProcessTaskStateEnum.DOING.getCode().equals(this.taskState)) {
            throw new RuntimeException("任务不是进行中状态,无法完成");
        }
        if (!isAllowed(operator)) {
            throw new RuntimeException("操作人不在参与者列表中");
        }
        // 状态转换
        this.taskState = ProcessTaskStateEnum.FINISHED.getCode();
        this.actorId = operator;
        // ...记录时间、合并变量
    }

    public boolean isAllowed(String operator) {
        if (FlowConst.AUTO_ID.equalsIgnoreCase(operator)
            || FlowConst.ADMIN_ID.equalsIgnoreCase(operator)) {
            return true;  // 系统代执行 / 超管放行
        }
        return isDoing() && this.actorIds != null
               && this.actorIds.contains(operator);
    }
}

规则跟着数据走------任务自己知道"谁能完成我"、"完成后状态怎么变"。外部调用方只需要说"完成这个任务",不需要知道内部校验逻辑。


三、聚合根:ProcessInstance

DDD 的聚合根(Aggregate Root)是一个事务边界------外部只能通过聚合根访问内部实体,所有状态修改都经过聚合根的方法。

jeeflow 的 ProcessInstance 就是这样一个聚合根:

java 复制代码
/**
 * 流程实例------DDD 聚合根(充血模型)
 *
 * <p>流程实例是一个独立的事务边界,包含所有子任务。
 * 所有状态修改都通过聚合根的方法完成,外部不直接操作子实体。</p>
 */
public class ProcessInstance {
    private Long instanceId;
    private Integer state;           // 实例状态
    private String operator;         // 发起人
    private FlowData variables;      // 流程变量(值对象)
    private List<ProcessTask> tasks; // 子实体集合
    // ...
}

3.1 命令方法清单

聚合根暴露的每一个方法,都是一个业务命令

方法 状态转换 说明
completeTask(taskId, operator, args) 子任务 10→20 完成任务 + 合并变量 + 提取 f_ 前缀表单数据
finish() 实例 10→20 所有任务完成,流程正常结束
reject() 实例 10→45 流程被拒绝
interrupt(operator) 所有子任务→40, 实例→40 强行终止(级联)
pending(operator) 所有子任务→50, 实例→50 挂起(级联)
resume(operator) 所有子任务 40→10, 实例→10 唤醒(级联)
withdraw(operator) 进行中子任务→30, 实例→30 撤回(级联)
abandonTask(taskId, operator) 子任务 10→99, 实例→99 废弃任务并废弃整个实例

注意级联 这个词------撤回/终止/挂起是实例级命令,但会级联到所有子任务。这是聚合根的职责:保证实例和任务的状态一致性。

3.2 不变量保护

聚合根最重要的作用之一是保护不变量(invariants)。看一个关键方法:

java 复制代码
private ProcessTask findDoingTask(Long taskId) {
    for (ProcessTask task : tasks) {
        if (taskId.equals(task.getTaskId())) {
            if (!task.isDoing()) {
                throw new RuntimeException(
                    "任务[" + taskId + "]不是进行中状态");
            }
            return task;
        }
    }
    throw new RuntimeException(
        "未找到任务[" + taskId + "]或不在聚合根中");
}

这个方法做了两件事:

  1. 存在性校验:任务必须属于这个聚合根
  2. 状态校验:任务必须是进行中

外部调用 completeTask 时,不需要重复这两个校验------聚合根已经保证了。

3.3 引擎只做编排

有了充血聚合根,引擎层就变成了薄编排

java 复制代码
// JeeflowEngineImpl.executeProcessTask 核心逻辑
public List<ProcessTask> executeProcessTask(
        Long taskId, String operator, FlowData args) {
    // 1. 加载聚合根
    ProcessInstance instance = repository.getInstance(defineId);

    // 2. 权限校验------委托给子实体
    ProcessTask task = instance.findDoingTask(taskId);
    if (!task.isAllowed(operator)) {
        throw new RuntimeException("无权限");
    }

    // 3. 完成任务------委托给聚合根
    instance.completeTask(taskId, operator, args);

    // 4. 路由到下一个节点------引擎的职责
    Execution exec = buildExecution(instance, args, operator);
    currentNode.execute(exec);

    // 5. 持久化------通过 SPI
    repository.updateInstance(instance);
    return instance.getDoingTasks();
}

引擎和聚合根的分工

引擎(编排) 聚合根(规则)
加载解析流程定义 完成任务时状态怎么变
决定下一个节点是谁 谁有权处理这个任务
评估决策表达式 驳回时哪些任务要废弃
解析参与者名单 任务创建时的字段约定
发布事件/调用拦截器 实例是否所有任务完成

判断标准:改动涉及"状态/字段规则"→ 聚合根;涉及"流程走向/外部 IO"→ 引擎。


四、子实体:ProcessTask

ProcessTask 是聚合根的子实体,它也有自己的行为:

java 复制代码
/**
 * 流程任务------聚合根 ProcessInstance 的子实体(充血模型)
 * <p>任务自己知道如何完成、废弃、判断权限。</p>
 */
public class ProcessTask {
    private Long taskId;
    private Long processInstanceId;  // 所属聚合根 ID
    private String taskName;         // 节点编码
    private Integer taskState;       // 任务状态
    private List<String> actorIds;   // 参与者列表
    // ...

    // 完成:10→20
    public void finish(String operator, FlowData args) { ... }

    // 废弃:10→99
    public void abandon(String operator) { ... }

    // 撤回:→30
    public void withdraw() { ... }

    // 终止:10→40(仅进行中可终止)
    public void interrupt(String operator) { ... }

    // 挂起:10→50
    public void pending(String operator) { ... }

    // 唤醒:40→10(仅已终止可唤醒)
    public void resume(String operator) { ... }
}

每个方法都有前置条件校验------不是简单的 setter,而是带业务规则的命令。

权限判断的内聚

isAllowed 方法把权限逻辑内聚在实体里:

java 复制代码
public boolean isAllowed(String operator) {
    // 系统代执行 / 超管 → 直接放行
    if (FlowConst.AUTO_ID.equalsIgnoreCase(operator)
        || FlowConst.ADMIN_ID.equalsIgnoreCase(operator)) {
        return true;
    }
    // 普通用户:必须是进行中 + 在参与者列表中
    return isDoing() && this.actorIds != null
           && this.actorIds.contains(operator);
}

如果权限规则变了(比如加一个"部门经理可代审"),只改这一个方法,不需要翻遍整个 Service。


五、值对象:FlowData

除了聚合根和子实体,jeeflow 还有一个值对象 FlowData

java 复制代码
// 继承 LinkedHashMap,零外部依赖
public class FlowData extends LinkedHashMap<String, Object> {
    public String getStr(String key) { ... }
    public Long getLong(String key) { ... }
    public Integer getInt(String key) { ... }
    public Boolean getBool(String key) { ... }
    public FlowData set(String key, Object value) { ... }  // 链式
    public FlowData copy() { ... }  // 深拷贝
}

为什么不用 Hutool Dict 或 Map<String, Object>?因为值对象要表达语义 ------FlowData 不是随便一个 Map,它是"流程变量",有类型安全的取值方法和深拷贝能力。


六、五语言同构

充血模型不是 Java 专属。jeeflow 的五语言实现都遵循同样的 DDD 结构:

语言 聚合根形式 关键文件
Java class + 方法 domain/ProcessInstance.java
Go struct + receiver 方法 model/instance.go
Node.js class(interface 承载不了行为) src/model.ts
Python dataclass + 方法 jeeflow/model.py
PHP class + 方法 Domain/ProcessInstance.php

同构的不是代码语法,而是职责边界 ------每个语言的 ProcessInstance 都有 completeTask/finish/reject/interrupt 这些命令方法,都有 findDoingTask 这个不变量保护。

这也是为什么 jeeflow 能做到"一套流程定义,五语言跑出相同结果"------规则在聚合根里,不在引擎编排里。换语言只是换语法,规则不变。


七、为什么贫血模型扛不住工作流

回到开篇的问题。

工作流引擎的复杂度不在"路由"(decision/fork/join 是算法问题),而在状态和规则的交织

  • 完成任务时要校验状态、权限、合并变量、判断是否全部完成、级联改实例状态
  • 驳回时要找到上一个任务节点、废弃当前路径上的所有进行中任务、创建新任务
  • 撤回时要级联所有子任务、但不能撤回已完成的子任务
  • 会签一票否决时要废弃其他参与者的待办任务

这些规则如果全堆在 Service 里:

  • Java 版 2000 行,Go 版再写 2000 行,Python 版再来 2000 行
  • 规则改了(比如加一个"部门经理可代审"),五个语言各改一处,漏一个就是 bug
  • 测试要 mock 整个 Service 依赖链

而充血模型把规则收拢到聚合根:

  • 规则只写一次(每个语言各写一次,但逻辑完全同构)
  • 改动只改一处isAllowed 方法加一行)
  • 测试只测聚合根(不需要 mock 引擎/仓储/SPI)

这就是 DDD 在工作流引擎场景下的核心价值:不是"架构洁癖",是"五语言同构"的工程刚需。


结语

流程图是皮,状态机是骨,聚合根是肉

理解了 ProcessInstance/ProcessTask 的充血设计,你就理解了 jeeflow 为什么能 98KB 跑完全套工作流语义------规则内聚在领域对象里,引擎只做薄编排

下一篇预告:[第 6 篇 · "applicant" 契约:退回发起人的闭环设计](#第 6 篇 · "applicant" 契约:退回发起人的闭环设计 "#") ------ 为什么每个流程的第一个节点必须是"发起申请"?退回发起人(submitType=6)的完整链路怎么走?


参考资料


下一篇预告 :[第 6 篇 · "applicant" 契约:退回发起人的闭环设计](#第 6 篇 · "applicant" 契约:退回发起人的闭环设计 "#") ------ 为什么每个流程的第一个节点必须是"发起申请"?退回发起人(submitType=6)的完整链路怎么走?

相关推荐
candyTong1 小时前
Claude Code 如何恢复一段会话
后端·架构·ai编程
mqiqe2 小时前
AgentScope Java Harness:4. 双层记忆系统 让 Agent 拥有真正的“长期大脑“
java·开发语言
伍一512 小时前
03-文件管理模块-一个看似简单的模块藏着多少细节
java·ruoyi·若依
gugucoding2 小时前
46. 【Java】JUC并发工具:让并发更简单
java·开发语言
Nebula_g3 小时前
JavaSE基础语法:面向对象高级(代码中的成分)
java·开发语言·编程·javase·技术栈·高级语法
AC赳赳老秦3 小时前
公开图片 OCR 数据提取:OpenClaw 合规采集公开信息图,识别文字与表格并转化为结构化数据
java·大数据·前端·数据库·python·php·openclaw
段一凡-华北理工大学5 小时前
AI推动工业智能化转型~系列文章20:工业 AI 平台架构:云-边-端协同的技术体系
人工智能·python·架构·工业平台·云-边协同
CodeBlog-star5 小时前
Harness Engineering:Pi Agent 架构深度解析
人工智能·python·架构·harness工程
IT大白鼠5 小时前
OpenBao开源密钥管理系统:技术架构、核心功能与行业应用研究
架构·开源·aiops·openbao