基于 Clean Architecture + DDD 的轻量级工作流系统实践

基于 Clean Architecture + DDD 的轻量级工作流系统实践

本文介绍在一个 .NET 10 + Vue 3 的后台管理系统(Ncp.Admin)中,如何基于现有的 Clean Architecture + DDD 架构,从零构建一套轻量级审批工作流系统,涵盖后端领域建模、CQRS 命令查询、领域事件驱动的业务自动化,以及前端可视化流程节点设计器的完整实现。

一、项目背景与技术栈

Ncp.Admin 是一套采用 Clean Architecture 分层架构的后台管理系统,技术栈如下:

层级 技术选型
前端 Vue 3 + Vite + Ant Design Vue (Vben Admin)
API 层 ASP.NET Core + FastEndpoints
应用层 MediatR (CQRS)、FluentValidation
领域层 DDD 聚合根、领域事件、强类型 ID
基础设施 EF Core + Pomelo MySQL、Redis、CAP、Hangfire

项目已经有完善的用户、角色、部门、权限管理模块。本次需求是在现有架构基础上,增加一套 审批工作流系统 ,支持流程定义、流程发起、多级审批、驳回、转办等能力,并实现 "新增用户需走审批流程" 的业务闭环。

二、为什么不用 Elsa Workflows?

在技术选型阶段,我们对比了 Elsa Workflows 和自建方案:

维度 Elsa Workflows 自建方案
功能丰富度 自带可视化设计器、条件分支、定时触发等 按需实现,功能精简
学习成本 需理解 Elsa 活动模型、序列化机制 复用现有 DDD 模式,团队零成本
架构耦合 引入独立的持久化层和运行时 完全融入现有分层架构
前端集成 自带 Blazor/React 设计器,与 Vue 生态不匹配 原生 Vue 3 + Ant Design Vue
数据库 默认 SQLite,MySQL 支持需额外配置 复用现有 EF Core + MySQL
.NET 版本 Elsa 3.x 对 .NET 10 的兼容性需验证 无兼容性风险

最终选择了 自建方案 ------ 对于审批类工作流,核心逻辑并不复杂,而自建方案可以完美融入现有 DDD 架构,代码风格统一,维护成本更低。

三、领域模型设计

3.1 聚合根划分

工作流系统划分为两个聚合:

复制代码
WorkflowDefinition (流程定义聚合)
├── WorkflowDefinitionId    // 强类型 ID
├── Name / Description / Category
├── Status (Draft → Published → Archived)
├── Version
├── Nodes: ICollection<WorkflowNode>  // 流程节点(值对象集合)
└── 领域方法: Publish(), Archive(), GetFirstApprovalNode(), GetNextApprovalNode()

WorkflowInstance (流程实例聚合)
├── WorkflowInstanceId      // 强类型 ID
├── WorkflowDefinitionId    // 关联定义
├── BusinessKey / BusinessType
├── Status (Running → Completed/Rejected/Cancelled)
├── Variables               // 业务数据 JSON
├── Tasks: ICollection<WorkflowTask>   // 审批任务集合
└── 领域方法: CreateTask(), ApproveTask(), RejectTask(), TransferTask(), Complete()

3.2 强类型 ID

与项目现有模式一致,所有聚合根使用强类型 ID:

csharp 复制代码
public partial record WorkflowDefinitionId : IGuidStronglyTypedId;
public partial record WorkflowInstanceId : IGuidStronglyTypedId;

3.3 流程定义聚合根

WorkflowDefinition 是流程模板的聚合根,封装了状态管理和 流程流转的领域逻辑

csharp 复制代码
public class WorkflowDefinition : Entity<WorkflowDefinitionId>, IAggregateRoot
{
    public WorkflowDefinitionStatus Status { get; private set; }
    public virtual ICollection<WorkflowNode> Nodes { get; init; } = [];

    // 状态变更 + 领域事件
    public void Publish()
    {
        if (Status == WorkflowDefinitionStatus.Published)
            throw new KnownException("流程定义已经发布", ErrorCodes.WorkflowDefinitionAlreadyPublished);

        Status = WorkflowDefinitionStatus.Published;
        AddDomainEvent(new WorkflowDefinitionPublishedDomainEvent(this));
    }

    // 流程流转逻辑下沉到聚合根(而非 Handler)
    public WorkflowNode? GetFirstApprovalNode()
        => GetOrderedApprovalNodes().FirstOrDefault();

    public WorkflowNode? GetNextApprovalNode(string currentNodeName)
    {
        var orderedNodes = GetOrderedApprovalNodes();
        var currentIndex = orderedNodes.ToList().FindIndex(n => n.NodeName == currentNodeName);
        return (currentIndex >= 0 && currentIndex < orderedNodes.Count - 1)
            ? orderedNodes[currentIndex + 1]
            : null;
    }
}

DDD 要点 :流转逻辑(获取首节点、下一节点)放在 WorkflowDefinition 聚合根而非 Command Handler 中。Handler 只负责编排调度,领域逻辑由聚合根保护。

3.4 流程实例聚合根

WorkflowInstance 管理一次具体的审批流程执行:

csharp 复制代码
public class WorkflowInstance : Entity<WorkflowInstanceId>, IAggregateRoot
{
    public string Variables { get; private set; } = "{}"; // 业务数据 JSON

    public WorkflowTask CreateTask(string nodeName, WorkflowTaskType taskType,
        UserId assigneeId, string assigneeName)
    {
        var task = new WorkflowTask(nodeName, taskType, assigneeId, assigneeName);
        Tasks.Add(task);
        CurrentNodeName = nodeName;
        AddDomainEvent(new WorkflowTaskCreatedDomainEvent(this, task));
        return task;
    }

    public void ApproveTask(WorkflowTaskId taskId, UserId operatorId, string comment)
    {
        var task = Tasks.FirstOrDefault(t => t.Id == taskId)
            ?? throw new KnownException("未找到该任务", ErrorCodes.WorkflowTaskNotFound);
        task.Approve(comment);
        AddDomainEvent(new WorkflowTaskCompletedDomainEvent(this, task));
    }

    public void Complete()
    {
        Status = WorkflowInstanceStatus.Completed;
        CompletedAt = DateTimeOffset.UtcNow;
        AddDomainEvent(new WorkflowInstanceCompletedDomainEvent(this));
    }
}

四、CQRS 命令与查询

4.1 发起流程命令

StartWorkflowCommand 演示了 Handler 如何 编排 聚合根交互:

csharp 复制代码
public class StartWorkflowCommandHandler(
    IWorkflowDefinitionRepository definitionRepository,
    IWorkflowInstanceRepository instanceRepository)
    : ICommandHandler<StartWorkflowCommand, WorkflowInstanceId>
{
    public async Task<WorkflowInstanceId> Handle(StartWorkflowCommand request, CancellationToken ct)
    {
        var definition = await definitionRepository.GetAsync(request.WorkflowDefinitionId, ct)
            ?? throw new KnownException("未找到流程定义");

        // 创建实例
        var instance = new WorkflowInstance(
            request.WorkflowDefinitionId, definition.Name,
            request.BusinessKey, request.BusinessType,
            request.Title, request.InitiatorId, request.InitiatorName,
            request.Variables, request.Remark);

        await instanceRepository.AddAsync(instance, ct);

        // 通过聚合根领域方法获取第一个审批节点(逻辑在 Definition 中)
        var firstNode = definition.GetFirstApprovalNode();
        if (firstNode != null && long.TryParse(firstNode.AssigneeValue, out var id))
        {
            instance.CreateTask(firstNode.NodeName, WorkflowTaskType.Approval,
                new UserId(id), string.Empty);
        }

        return instance.Id;
    }
}

4.2 审批命令 --- 自动流转

csharp 复制代码
public class ApproveTaskCommandHandler(
    IWorkflowInstanceRepository instanceRepository,
    IWorkflowDefinitionRepository definitionRepository) : ICommandHandler<ApproveTaskCommand>
{
    public async Task Handle(ApproveTaskCommand request, CancellationToken ct)
    {
        var instance = await instanceRepository.GetAsync(request.WorkflowInstanceId, ct);
        instance.ApproveTask(request.TaskId, request.OperatorId, request.Comment);

        var definition = await definitionRepository.GetAsync(instance.WorkflowDefinitionId, ct);
        var approvedTask = instance.Tasks.First(t => t.Id == request.TaskId);

        // 领域方法:获取下一节点
        var nextNode = definition.GetNextApprovalNode(approvedTask.NodeName);

        if (nextNode != null)
        {
            // 创建下一个审批任务
            instance.CreateTask(nextNode.NodeName, WorkflowTaskType.Approval, ...);
        }
        else
        {
            // 所有节点审批完毕,流程完成
            instance.Complete();
        }
    }
}

五、领域事件驱动的业务自动化

5.1 领域事件定义

csharp 复制代码
public record WorkflowDefinitionPublishedDomainEvent(WorkflowDefinition WorkflowDefinition) : IDomainEvent;
public record WorkflowInstanceStartedDomainEvent(WorkflowInstance WorkflowInstance) : IDomainEvent;
public record WorkflowInstanceCompletedDomainEvent(WorkflowInstance WorkflowInstance) : IDomainEvent;
public record WorkflowTaskCreatedDomainEvent(WorkflowInstance WorkflowInstance, WorkflowTask WorkflowTask) : IDomainEvent;
public record WorkflowTaskCompletedDomainEvent(WorkflowInstance WorkflowInstance, WorkflowTask WorkflowTask) : IDomainEvent;

5.2 审批通过后自动执行业务操作

这是整个系统的亮点设计 ------ 通过领域事件实现 流程与业务的解耦

csharp 复制代码
public class WorkflowInstanceCompletedDomainEventHandler(IMediator mediator, RoleQuery roleQuery)
    : IDomainEventHandler<WorkflowInstanceCompletedDomainEvent>
{
    public async Task Handle(WorkflowInstanceCompletedDomainEvent domainEvent, CancellationToken ct)
    {
        var instance = domainEvent.WorkflowInstance;
        if (instance.Status != WorkflowInstanceStatus.Completed) return;

        switch (instance.BusinessType)
        {
            case "CreateUser":
                await HandleCreateUser(instance, ct);
                break;
            // 后续可扩展:case "PurchaseOrder": ...
        }
    }

    private async Task HandleCreateUser(WorkflowInstance instance, CancellationToken ct)
    {
        // 从 Variables JSON 中反序列化用户数据
        var userData = JsonSerializer.Deserialize<CreateUserVariables>(instance.Variables);

        // 复用现有的 CreateUserCommand
        var cmd = new CreateUserCommand(
            userData.Name, userData.Email, userData.Password, ...);
        await mediator.Send(cmd, ct);
    }
}

设计思想 :前端提交审批时,将完整的业务数据(如用户信息)序列化为 JSON 存入 Variables 字段。审批通过后,领域事件处理器从 Variables 中反序列化数据,调用对应的业务 Command 完成操作。这样 工作流引擎本身不需要了解任何业务细节 ,新增业务类型只需在 switch 中扩展即可。

六、前端可视化节点设计器

6.1 设计思路

传统的做法是让用户编辑 JSON 来配置流程节点,这显然不够友好。我们实现了一个 基于竖向流程图的可视化节点设计器

复制代码
    [▶ 开始]
       │
       ↓
  ┌──────────────┐
  │ ✓ 主管审批     │  ← 可编辑卡片
  │ 类型: 审批     │
  │ 处理人: 张三   │
  └──────────────┘
       │
      (+)           ← 点击插入新节点
       │
  ┌──────────────┐
  │ ✓ 总监审批     │
  │ 类型: 审批     │
  │ 处理人: 李四   │
  └──────────────┘
       │
       ↓
    [■ 结束]

6.2 组件实现

node-designer.vue 是一个完整的 Vue 3 组件,核心设计如下:

交互能力

  • 添加节点(顶部、中间、底部均可插入)
  • 删除节点(带 Popconfirm 二次确认)
  • 上下移动节点(调整审批顺序)
  • 配置节点属性(名称、类型、处理人类型、处理人)
  • 已发布流程只读,不可编辑

视觉设计

  • 开始/结束节点使用渐变色圆形标识
  • 节点卡片顶部彩色色条标识类型(蓝色=审批、绿色=抄送、橙色=通知)
  • 连接线带有方向箭头
  • 悬浮动效(卡片微浮、操作按钮渐显、添加按钮缩放高亮)
  • 表单双列布局节省空间

核心代码片段

typescript 复制代码
// 节点类型视觉配置
const nodeTypeConfig: Record<number, { color: string; bg: string; icon: string }> = {
  1: { color: '#1677ff', bg: '#e6f4ff', icon: '✓' },  // 审批
  2: { color: '#52c41a', bg: '#f6ffed', icon: '📋' },  // 抄送
  3: { color: '#faad14', bg: '#fffbe6', icon: '🔔' },  // 通知
};

6.3 分类下拉选择

流程定义的「分类」字段从自由文本输入改为下拉选择,统一维护枚举值:

typescript 复制代码
export function useCategoryOptions() {
  return [
    { label: '用户管理', value: 'UserManagement' },
    { label: '角色管理', value: 'RoleManagement' },
    { label: '请假审批', value: 'LeaveRequest' },
    { label: '采购审批', value: 'PurchaseOrder' },
    { label: '报销审批', value: 'Reimbursement' },
    { label: '通用流程', value: 'General' },
  ];
}

前端查找对应流程定义时使用枚举值精确匹配,不再依赖中文字符串:

typescript 复制代码
const userCreateDef = definitions.find(
  (d) => d.category === 'UserManagement',
);

七、操作指南:如何创建流程与审批

下面从使用角度说明:如何创建一条自定义工作流程 ,以及 审批人在哪里处理待办

7.1 如何创建一个自定义工作流程

  1. 进入 工作流管理 → 流程定义
  2. 点击 新增,打开流程定义表单。
  3. 填写 流程名称分类 (从下拉选择,如「用户管理」「请假审批」等)、描述
  4. 流程节点设计 区域配置审批节点:
    • 点击「+ 添加节点」或节点之间的「+」插入节点;
    • 为每个节点填写 节点名称 ,选择 节点类型(审批 / 抄送 / 通知);
    • 选择 处理人类型 (指定用户、指定角色、部门主管、发起人自选),若为指定用户或指定角色,再选择具体 处理人
    • 通过 上移 / 下移 调整节点顺序,通过 删除 移除节点。
  5. 保存后,在列表中找到该流程,点击 发布。只有已发布的流程才能被发起。

流程定义列表:可新增、编辑、发布、归档;编辑时在下方进行流程节点设计。

7.2 在哪里审批

审批人的待办任务在 工作流管理 → 我的待办 中处理:

  1. 登录后进入 工作流管理 菜单,点击 我的待办
  2. 列表中展示当前用户作为处理人的所有待审批任务(流程标题、流程名称、发起人、节点名称等)。
  3. 点击 办理 进入详情,可查看流程信息与业务数据(如用户申请内容),进行 通过驳回转办 操作。
  4. 已处理的任务可在 我的已办 中查看历史记录。

我的待办:审批人在此处理待审批任务。

八、新增用户走审批流程 --- 完整链路

这是一个典型的端到端示例,展示工作流如何与具体业务打通:

8.1 前端 --- 提交审批

系统管理 → 用户管理 的新增用户表单中,提供「提交审批」按钮。用户填写完账号、姓名、角色等信息后,可选择直接保存(若有权限)或 提交审批。点击「提交审批」后:

  1. 验证表单数据
  2. 查询已发布的流程定义,匹配分类为「用户管理」的定义
  3. 将用户表单数据 JSON 序列化为 variables
  4. 调用 startWorkflow API 发起审批

新增用户时可选择「提交审批」,进入已配置的用户管理审批流程。

typescript 复制代码
async function onSubmitForApproval() {
  const { valid } = await formApi.validate();
  if (!valid) return;

  const definitions = await getPublishedDefinitions();
  const userCreateDef = definitions.find(d => d.category === 'UserManagement');

  const formValues = await formApi.getValues();
  const variables = JSON.stringify({
    name: formValues.name,
    email: formValues.email,
    password: formValues.password,
    realName: formValues.realName,
    roleIds: formValues.roleIds || [],
    // ... 其他字段
  });

  await startWorkflow({
    workflowDefinitionId: userCreateDef.id,
    businessKey: `user-create-${Date.now()}`,
    businessType: 'CreateUser',
    title: `新增用户申请 - ${formValues.realName}`,
    variables,
  });
}

8.2 后端 --- 审批流转

复制代码
提交审批 → StartWorkflowCommand
         → 创建 WorkflowInstance
         → Definition.GetFirstApprovalNode() → 创建第一个 Task

审批通过 → ApproveTaskCommand
         → Instance.ApproveTask()
         → Definition.GetNextApprovalNode()
         → 有下一节点 → 创建新 Task
         → 无下一节点 → Instance.Complete()
                       → 触发 WorkflowInstanceCompletedDomainEvent

领域事件 → WorkflowInstanceCompletedDomainEventHandler
         → BusinessType == "CreateUser"
         → 反序列化 Variables → CreateUserCommand → 用户创建成功

8.3 流程图

复制代码
[用户填写表单] → [提交审批] → [主管审批] → [总监审批] → [审批通过]
                                                         ↓
                                                   [领域事件触发]
                                                         ↓
                                                  [自动创建用户]

九、架构亮点总结

9.1 DDD 原则贯穿始终

原则 实践
聚合根封装 状态变更、流转逻辑、业务规则校验均在聚合根内
领域事件 每个关键状态变更都发布对应领域事件
强类型 ID WorkflowDefinitionIdWorkflowInstanceId 避免 ID 误用
值对象 WorkflowNode 作为 WorkflowDefinition 的子实体集合

9.2 CQRS 分离清晰

  • Command 侧StartWorkflowCommandApproveTaskCommandRejectTaskCommand 等,Handler 只做编排
  • Query 侧WorkflowDefinitionQueryWorkflowInstanceQuery,使用 AsNoTracking() 优化性能,配合 IMemoryCache 缓存高频数据

9.3 业务与流程解耦

复制代码
工作流引擎(通用)          业务处理(特定)
─────────────────         ─────────────────
WorkflowInstance           ↗ CreateUserCommand
  .Complete()              │
  → DomainEvent  ──────→  EventHandler (switch BusinessType)
                           │
                           ↘ 其他业务 Command

新增业务类型时:

  1. 前端新增提交入口,传入 businessTypevariables
  2. 后端在 WorkflowInstanceCompletedDomainEventHandlerswitch 中增加分支
  3. 流程引擎本身无需任何修改

9.4 前端体验优化

  • 可视化节点设计器替代 JSON 编辑,降低使用门槛
  • 分类枚举化,避免自由文本带来的匹配错误
  • i18n 国际化支持中英文
  • 已发布流程自动锁定为只读模式

十、后续规划

  1. 条件分支节点:根据表单字段值走不同审批路径
  2. 会签/或签:一个节点可配置多个审批人
  3. 审批催办:基于 Hangfire 定时检查超时任务
  4. 流程统计看板:审批效率、瓶颈节点分析
  5. 移动端适配:审批任务推送 + 移动端快速审批

十一、结语

一套好的工作流系统,核心不在于功能有多丰富,而在于 与现有架构的融合度业务扩展的便捷性

本次实践证明,在 Clean Architecture + DDD 的项目中,自建轻量级工作流是完全可行的。通过聚合根封装流转逻辑、领域事件驱动业务自动化、CQRS 分离读写关注点,我们用不到 2000 行后端代码就实现了一套 可用、可扩展、架构一致 的审批系统。

前端方面,一个 600 行的 Vue 组件就搭建起了直观的可视化节点设计器,配合 Ant Design Vue 的组件库,用户体验也做到了开箱即用。

不是所有场景都需要引入重量级的工作流引擎,适合的才是最好的。