领域驱动设计(DDD):限界上下文、实体与值对象、聚合与仓储、领域事件在因子工厂中的应用

说实话,DDD 这个概念,很多人一上来就被绕晕了。什么限界上下文、聚合根、领域事件……听着像是一堆玄学术语。但我做了这么多年后端,回头再看,DDD 其实就是在帮我们回答一个核心问题:业务逻辑到底该写在哪?

在因子工厂这个项目里,我们面对的是复杂的因子计算、策略编排、回测调度。如果不用 DDD 的思路去划分边界,代码很快就会变成一锅粥。今天我就带你走一遍,看看 DDD 的这几个核心概念,在因子工厂里到底是怎么落地。

1. 限界上下文:划清地盘的边界

限界上下文,说白了就是划地盘。每个上下文有自己的语言、自己的模型、自己的规则。你不能把回测的模型拿到因子计算里去用,那会乱套。

在因子工厂里,我划分了这么几个限界上下文:

  • 因子管理上下文:负责因子的定义、注册、版本管理。这里只关心因子是什么,不关心怎么算。
  • 因子计算上下文:负责具体的计算逻辑。输入原始数据,输出因子值。这里只关心计算效率,不关心因子怎么来的。
  • 策略编排上下文:负责把多个因子组合成策略。这里只关心组合规则,不关心单个因子怎么算。
  • 回测调度上下文:负责调度回测任务、管理回测结果。这里只关心任务状态,不关心策略内容。

每个上下文之间通过领域事件防腐层通信。举个例子,因子计算完成后,会发出一个 FactorCalculatedEvent,策略编排上下文订阅这个事件,然后决定是否触发策略重算。这样,两个上下文完全解耦。

核心原则:一个限界上下文内部,模型是自洽的。跨上下文时,不要直接引用对方的实体,而是通过事件或DTO传递数据。

我记得有一次,团队里有人直接把因子计算的实体类 import 到了策略编排的模块里。我一看就摇头——这等于把两个地盘打通了,以后改一个地方,另一个地方也得跟着改。后来我强制要求:跨上下文必须走接口或事件,谁违反谁重构。

2. 实体与值对象:区分“你是谁”和“你是什么”

实体和值对象的区别,我经常用一个例子来解释:

  • 实体:有唯一标识,即使属性变了,它还是它。比如一个因子,它的 ID 是 factor-001,就算改了计算逻辑,它还是那个因子。
  • 值对象:没有唯一标识,只关心属性值。比如一个 FactorValue,它包含时间戳和数值,两个 FactorValue 如果时间戳和数值都一样,那它们就是同一个东西。

在因子工厂里,我这样设计:

概念 示例 说明
实体 Factor 有唯一ID,属性可变(如计算参数)
实体 Strategy 有唯一ID,属性可变(如权重配置)
值对象 FactorValue 无ID,由时间戳+数值组成,不可变
值对象 TimeRange 无ID,由开始时间+结束时间组成,不可变

你想想看,如果把 FactorValue 设计成实体,每次计算都要生成一个新 ID,那数据库里会多出多少垃圾数据?而且两个相同的值对象,因为 ID 不同,会被当成不同的东西,逻辑上就错了。

我的习惯:值对象一定要设计成不可变的。用 Java 的话,所有字段都加 final,不提供 setter。用 Go 的话,用结构体值传递,不要传指针。这样可以避免很多诡异的 bug。

3. 聚合与仓储:保证数据一致性

聚合,是 DDD 里最容易被误解的概念。很多人以为聚合就是一组对象的集合,其实不然。聚合的核心是事务边界——一个聚合内的所有修改,要么全部成功,要么全部失败。

在因子工厂里,我设计了一个典型的聚合:Strategy 聚合。

// 策略聚合根
public class Strategy {
    private StrategyId id;
    private String name;
    private List<StrategyNode> nodes;  // 策略节点,属于聚合内部
    private List<StrategyEdge> edges;  // 策略边,属于聚合内部

    // 只有通过聚合根才能修改内部对象
    public void addNode(StrategyNode node) {
        // 校验节点是否合法
        // 添加到 nodes 列表
        // 发出领域事件
    }

    public void removeNode(String nodeId) {
        // 校验节点是否存在
        // 从 nodes 中移除
        // 同时移除关联的 edges
        // 发出领域事件
    }
}

注意看,StrategyNodeStrategyEdge 没有对外暴露的仓储接口。你不能直接去数据库里查某个节点,然后修改它。所有操作都必须通过 Strategy 这个聚合根。为什么?因为节点和边是强关联的,你删了一个节点,如果不删对应的边,数据就坏了。

仓储呢?仓储就是聚合根的“管家”。它负责把聚合从数据库里拿出来,或者存回去。在因子工厂里,我这样定义仓储接口:

public interface StrategyRepository {
    Strategy findById(StrategyId id);
    void save(Strategy strategy);
    void delete(StrategyId id);
}

仓储只操作聚合根,不操作内部对象。你想想看,如果仓储直接暴露 saveNode() 方法,那业务逻辑就散落到各个地方了,聚合的封装性就没了。

我曾经踩过的坑:一开始我把 StrategyNode 也单独设计了仓储,结果业务代码里到处都是先查 Strategy,再查 Node,然后分别保存。有一次并发场景下,两个请求同时修改同一个策略的不同节点,导致数据不一致。后来我痛下决心,把所有节点操作都收回到聚合根里,问题才解决。

4. 领域事件:解耦的利器

领域事件,说白了就是“发生了什么事”。它让不同的限界上下文之间,或者同一个上下文内的不同模块之间,能够异步通信。

在因子工厂里,领域事件的应用场景非常多:

  • 因子计算完成:发出 FactorCalculatedEvent,通知策略编排模块重新评估策略。
  • 策略状态变更:发出 StrategyStatusChangedEvent,通知回测调度模块启动或停止回测。
  • 回测任务完成:发出 BacktestCompletedEvent,通知通知模块发送结果给用户。

我一般这样设计领域事件:

public class FactorCalculatedEvent {
    private String factorId;
    private String factorName;
    private LocalDateTime calculatedAt;
    private Map<String, Double> latestValues;  // 最新的因子值

    // 构造函数、getter 省略
}

事件本身是一个值对象,不可变。发布事件时,我习惯用事件总线(Event Bus)或者消息队列。在单体应用里,用 Guava 的 EventBus 就够了;在微服务架构里,用 Kafka 或 RabbitMQ。

嗯,这里要注意:领域事件不要包含太多数据。只传递必要的信息,比如因子 ID 和计算时间。具体的因子值,如果订阅方需要,可以再去查询。这样可以避免事件体过大,也减少了耦合。

我的经验:领域事件的名字一定要用过去时。比如 FactorCalculatedEvent,表示“因子已经被计算了”。不要用 CalculateFactorEvent,那听起来像是一个命令,而不是一个事件。命令和事件是两回事,混在一起会让代码很难理解。

5. 知识体系总览

说了这么多,我画了一张图,帮你把 DDD 在因子工厂里的应用串起来:

因子工厂 DDD 核心概念关系图 因子管理上下文 实体:Factor 值对象:FactorConfig 仓储:FactorRepository 因子计算上下文 实体:CalculationTask 值对象:FactorValue 事件:FactorCalculatedEvent 策略编排上下文 聚合:Strategy 值对象:StrategyNode 仓储:StrategyRepository 回测调度上下文 实体:BacktestJob 值对象:BacktestResult 事件:BacktestCompletedEvent 因子定义 FactorCalculatedEvent StrategyStatusChangedEvent 图例: 限界上下文 同步调用 领域事件(异步) 每个上下文内部:实体 + 值对象 + 聚合 + 仓储

从这张图里你能看到,每个限界上下文内部都有自己的实体、值对象、聚合和仓储。上下文之间通过领域事件或同步调用通信。这样设计的好处是:每个上下文可以独立演进,互不干扰

6. 避坑指南

最后,分享几个我在实践中踩过的坑:

  • 不要把值对象当实体用:我曾经把 FactorValue 设计成实体,结果每次计算都生成新 ID,数据库里全是垃圾数据。后来改成值对象,用时间戳+因子ID做唯一约束,清爽多了。
  • 聚合不要太大:一个聚合里包含几十个对象,那事务边界就太大了,性能会出问题。我一般控制一个聚合不超过 5-10 个对象。
  • 领域事件不要滥用:不是所有操作都要发事件。只有那些对别的上下文有意义的操作,才值得发事件。比如因子计算完成,策略编排需要知道;但因子内部的一个临时状态变更,就不需要发事件。
  • 仓储不要暴露内部对象:仓储只操作聚合根。如果你发现需要单独查询聚合内部的某个对象,那说明你的聚合边界可能划错了。

好了,DDD 在因子工厂里的应用,我就讲到这里。这些概念看起来抽象,但只要你动手去划分边界、设计聚合、定义事件,慢慢就会找到感觉。记住一句话:DDD 不是银弹,但它能帮你把复杂业务理清楚