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

0 阅读9分钟

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


一、一个反直觉的问题

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

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

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

那它们在哪?

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

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


二、贫血模型 vs 充血模型

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

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

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

// 贫血实体:只有 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 的做法:

// 充血实体:自己知道怎么"完成"
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 就是这样一个聚合根:

/**
 * 流程实例——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)。看一个关键方法:

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 引擎只做编排

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

// 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 是聚合根的子实体,它也有自己的行为:

/**
 * 流程任务——聚合根 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 方法把权限逻辑内聚在实体里:

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

// 继承 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 结构:

语言聚合根形式关键文件
Javaclass + 方法domain/ProcessInstance.java
Gostruct + receiver 方法model/instance.go
Node.jsclass(interface 承载不了行为)src/model.ts
Pythondataclass + 方法jeeflow/model.py
PHPclass + 方法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" 契约:退回发起人的闭环设计 —— 为什么每个流程的第一个节点必须是"发起申请"?退回发起人(submitType=6)的完整链路怎么走?


参考资料


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