中文资讯 掘金 AI Sun, 16 Aug 2026 正文 ok 693 words

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

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

ProcessInstance 为什么必须是聚合根?ProcessTask 作为子实体的职责边界在哪?为什么贫血模型扛不住工作流场景?从源码逐层拆解 jeeflow 的 DDD 设计。
打开原文相关 2 · 机会 0
media3.2 KB media589 B media6.7 KB

中文

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


一、一个反直觉的问题

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

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

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

那它们在哪?

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

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


二、贫血模型 vs 充血模型

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

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

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

public class ProcessInstance {
    private Long instanceId;
    private Integer state;
    private List<ProcessTask> tasks;
    
}


public class ProcessService {
    public void completeTask(Long taskId, String operator) {
        
        
        
        
        
        
        
        
        
        
        
    }
}

问题在哪?

  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 就是这样一个聚合根:

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

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

public List<ProcessTask> executeProcessTask(
        Long taskId, String operator, FlowData args) {
    
    ProcessInstance instance = repository.getInstance(defineId);

    
    ProcessTask task = instance.findDoingTask(taskId);
    if (!task.isAllowed(operator)) {
        throw new RuntimeException("无权限");
    }

    
    instance.completeTask(taskId, operator, args);

    
    Execution exec = buildExecution(instance, args, operator);
    currentNode.execute(exec);

    
    repository.updateInstance(instance);
    return instance.getDoingTasks();
}

引擎和聚合根的分工 :

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

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


四、子实体:ProcessTask

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

public class ProcessTask {
    private Long taskId;
    private Long processInstanceId;  
    private String taskName;         
    private Integer taskState;       
    private List<String> actorIds;   
    

    
    public void finish(String operator, FlowData args) { ... }

    
    public void abandon(String operator) { ... }

    
    public void withdraw() { ... }

    
    public void interrupt(String operator) { ... }

    
    public void pending(String operator) { ... }

    
    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:

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" 契约:退回发起人的闭环设计 —— 为什么每个流程的第一个节点必须是"发起申请"?退回发起人(submitType=6)的完整链路怎么走?


参考资料


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

English / 原文

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


一、一个反直觉的问题

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

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

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

那它们在哪?

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

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


二、贫血模型 vs 充血模型

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

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

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

public class ProcessInstance {
    private Long instanceId;
    private Integer state;
    private List<ProcessTask> tasks;
    
}


public class ProcessService {
    public void completeTask(Long taskId, String operator) {
        
        
        
        
        
        
        
        
        
        
        
    }
}

问题在哪?

  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 就是这样一个聚合根:

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

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

public List<ProcessTask> executeProcessTask(
        Long taskId, String operator, FlowData args) {
    
    ProcessInstance instance = repository.getInstance(defineId);

    
    ProcessTask task = instance.findDoingTask(taskId);
    if (!task.isAllowed(operator)) {
        throw new RuntimeException("无权限");
    }

    
    instance.completeTask(taskId, operator, args);

    
    Execution exec = buildExecution(instance, args, operator);
    currentNode.execute(exec);

    
    repository.updateInstance(instance);
    return instance.getDoingTasks();
}

引擎和聚合根的分工 :

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

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


四、子实体:ProcessTask

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

public class ProcessTask {
    private Long taskId;
    private Long processInstanceId;  
    private String taskName;         
    private Integer taskState;       
    private List<String> actorIds;   
    

    
    public void finish(String operator, FlowData args) { ... }

    
    public void abandon(String operator) { ... }

    
    public void withdraw() { ... }

    
    public void interrupt(String operator) { ... }

    
    public void pending(String operator) { ... }

    
    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:

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" 契约:退回发起人的闭环设计 —— 为什么每个流程的第一个节点必须是"发起申请"?退回发起人(submitType=6)的完整链路怎么走?


参考资料


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