在前文《当我们谈论 DDD 时我们在谈论什么》中我们讨论了 DDD 的战略设计和战术设计。在本文中我们将继续探讨领域模型。
用领域模型表达领域概念
在实际项目中,模型设计者往往过早陷入具体构造块类型的识别,比如实体、聚合、领域服务,而忽略了领域模型表达领域概念的目的。我们应该基于领域概念设计领域模型,然后再采用合适的模式降低领域模型的复杂度,进一步增加领域模型的表达能力。
领域模型的作用,一方面是关联代码实现,一方面是关联通用语言。我们对于模型和实现的关联轻车熟路,但是对于语言和模型关联往往有待提升。在沟通中刻意使用通用语言可以帮助我们验证模型的合理性。
我们以一个题目为例,方便后续讨论。
活动平台提供用户参与活动得到奖品的功能,吸引用户及潜在用户参与,以达到拉新、促活、引流的目的。
运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的开始时间和结束时间、参与资格、权益。
用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合资格的用户就会获得一份奖品。权益可能是信用卡积分,也可能是优惠券。
参与资格可能是:一天内注册的用户、VIP 用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。
活动平台提供用户参与活动得到奖品的功能,吸引用户及潜在用户参与,以达到拉新、促活、引流的目的。
运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的开始时间和结束时间、参与资格、权益。
用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合资格的用户就会获得一份奖品。权益可能是信用卡积分,也可能是优惠券。
参与资格可能是:一天内注册的用户、VIP 用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。
对于一个活动,一个用户只能参加一次。对于一个活动,一个用户只能参加一次。
建立模型
第一步是根据需求分析模型。
我们可以找到以下概念:活动、参与资格、权益。其中参与资格是扩展点。
对于需求「一个用户只能参加一次活动的」,需要记录用户是否参与过活动,所以需要「活动参与记录」的概念。
参与活动的结果可能有 2 种:符合参与资格则返回权益,不符合则返回「不符合」。所以我们用了 Optional<权益>类型。
我们到这里只识别了各种名词,需要走查用例,寻找缺失的概念:
- 用例1,运营人员可以创建并修改活动
- 用例2,用户可以参与活动并获得权益
对于用例1,创建和修改活动,目前模型已经满足了需求。
对于用例2,这里有一个模型之外的规则:「一个用户只能参加一次活动」。这是所有活动都需要遵守的规则,我们将其称为「活动通用规则」。
虽然只是一个很简单的逻辑,但是提取「活动通用规则」这个概念非常有用。如果没有这个概念,那么每次去描述这个概念,只能用「一个用户只能参加一次活动的规则」去表示,非常繁琐;也让概念没有安身之地,容易被随便放到万能的 Service 中。
我们将其加入领域模型。
PS:这里故意省略了参与资格的实现。
我们没有把「活动通用规则」放到活动概念里,一部分原因是这个判断逻辑不需要具体活动的信息。
使用通用语言验证模型
有了领域模型,就有了通用语言。使用通用语言重新描述需求,并尽量在沟通中使用通用语言。
运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的开始时间和结束时间时间段、参与资格、权益。
用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合资格参与资格的用户就会获得一份奖品权益。权益可能是信用卡积分,也可能是优惠券。
参与资格可能是:一天内注册的用户、VIP 用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。
运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的
开始时间和结束时间时间段、参与资格、权益。
用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合资格参与资格的用户就会获得一份奖品权益。权益可能是信用卡积分,也可能是优惠券。
参与资格可能是:一天内注册的用户、VIP 用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。
同时有「活动通用规则」:对于一个活动,一个用户只能参加一次。同时有「活动通用规则」:对于一个活动,一个用户只能参加一次。
这里去掉了「开始时间」、「资格」、「奖品」等模糊不清晰的描述。使用基于领域模型的语言,让需求描述清晰没有歧义。
到目前为止,主要的领域模型都已经分析出来。所有的模型都对应明确的领域概念,不多也不少。
识别构造块类型
在分析了领域模型后,我们再来分析构造块类型。
我们通过是否有状态来做区分。
首先识别有状态的对象:活动、各种参与资格、权益、活动参与记录、用户。一般有状态的对象都是事物,对应的构造块类型也就是实体或者值对象。
其次判断其状态是否会改变:
- 活动会被修改,所以状态会被改变;
- 参与资格会被修改,但是参与资格从属于活动,修改后可以直接使用新的对象替换旧的,所以可以设计成状态不变;
- 权益和参与资格一样,也可以设计成状态不变;
- 活动参与记录,状态可能发生变化;
- 用户在这个模型中只是临时存在,状态不会变化。
状态会改变的是实体,包括活动和活动参与记录;状态不变的就是值对象,包括参与资格、权益和用户。
最后剩下的就是无状态的对象:活动通用规则。对应的构造块类型是领域服务。
这里的无状态对象大都可以转化成有状态的对象,例如活动通用规则,可以将方法参数的 Optional<活动参与记录>变成成员变量。只是这里我们选择了无状态的设计方法。
由于领域服务没有状态,所以可以在应用启动时就创建出来,也可以在使用时才创建。
经过分析,我们的领域模型都有了类型。
设计聚合
首先识别生命周期长的领域对象:在一个操作中被创建出来,操作结束后仍会被其他操作使用的对象。活动、参与资格、权益和活动参与记录都是生命周期长的对象。
其他有状态的对象都是临时对象:在一个操作中被创建出来,操作结束后就不会再被使用。模型中的用户,在一次操作中从其他服务获取,使用后即被丢弃。
这里我们总结下各构造块类型的特点:
实体 | 值对象 | 领域服务 | |
是否有状态 | 有且状态可变 | 有且状态不可变 | 无 |
生命周期 | 长 | 长或者短 | 长短均可 |
在生命周期的长的对象中,我们要设计聚合。聚合作为操作单元,主要解决以下几个问题:
- 整个模型往往庞大复杂,为了降低知识负载,需要将其分解成多个小且简单的模型,划分清晰的边界
- 部分模型对象之间存在一致性规则,例如需要被一起删除,所以需要放在一个操作中
- 多个用户可能会并发操作模型,为了避免相互干扰,需要让操作单元尽可能小
- 对于操作单元,需要将其频繁加载到内存中,如果单元过大,往往不能满足性能要求
根据对业务的了解,活动及参与资格、权益都是一起被创建和修改,可以放在一个聚合里;活动和活动参与记录之间没有一致性规则,可以分开;因为活动参与记录数量会很多,如果和活动在一个聚合中,会降低性能。
所以我们将活动、参与资格、权益设计成一个聚合,而活动参与记录作为一个单独的聚合。而活动和活动参与记录分别作为这两个聚合的聚合根。对应的,聚合都会配备其专属的 Repository。
同时加上遍历方向箭头。由于活动是聚合根,从活动可以遍历到聚合内部的参与资格和权益。另外查询活动参与记录,可以通过其 Repository,所以没有活动到活动参与记录的箭头。
由于我们将活动和活动参与记录之间划分成不同聚合,那他们之间的关联将使用聚合的 ID 来关联,而不是聚合本身。
PS:如果使用了关联对象,遍历方向也可以是从活动到活动参与记录。
如何使用领域模型
领域模型已经建立完毕,我们来看如何使用领域模型以满足用例。
运营人员创建活动基本信息及其关联的参与资格和权益。领域模型的客户(一般来说是应用服务),使用运营人员输入的参数构造出活动对象,再利用 Repository 将其保存。
运营人员修改活动。应用服务利用 Repository 获取需要修改的活动,再根据运营人员提供的参数修改活动,最后利用 Repository 保存活动对象。
用户参与活动。应用服务:
- 使用活动通用规则判断用户是否可以参加。由于活动通用规则需要用到活动参与记录,因此应用服务会使用 Repository 获取活动参与记录;
- 如果可以参加,则执行活动的参与活动方法获得结果。这需要利用 Repository 获取用户参与的活动,并构造用户对象(可能需要调用用户服务获取用户信息,但是领域层并不关心这些逻辑);
- 如果结果是获得权益,则创建活动参与记录,并利用 Repository 保存。
考虑到并发情况,应用服务可以在第 1 步前加锁,并在第 3 步后释放锁。
再次思考
- 配置和参与活动可否是两个模型?
在实现运营人员配置活动的用例过程中,我们会发现可能找到了一个隐藏的领域概念,将输入的参数转换成领域模型的逻辑有些枯燥和复杂,同样将领域模型和数据库的数据模型之间转换也如此。输入参数和数据模型都是只是扁平的数据数据,没有继承结构。如果使用另外一种面向数据的模型,也许这些用例实现起来会简单得多。
每个模型都是为了解决某个问题。这里运营人员配置和用户参与活动是不同的问题,如果用一个模型来解决这两个问题,可能会有些吃力。那么干脆设计成两个模型,使用限界上下文的概念将这两个模型限定在各自的上下文中,也许更加合理。两个模型可以共享同一份数据库数据,并加上一段(非领域层的)逻辑用于模型之间的转换。
这实际上是一种配置-使用模式。在配置阶段,注重配置类型和参数、审批等;在使用阶段,注重逻辑计算和性能。
- 活动参与记录是否可以建模成领域事件?
活动参与记录实际上是不可变的,可以将其设计为领域事件。
- 用户参与活动的用例里,逻辑复杂,有泄漏领域概念的嫌疑?
如果发现应用服务里逻辑变得复杂,可能意味着我们找到了一个隐藏的领域概念。我们可以定义一个「用户参与活动逻辑」的概念:如果用户通过了活动通用规则的判断,则可以参与活动。将其加入模型和通用语言中,在沟通中验证此概念是否合理。
总结
很多项目虽然也使用了以领域模型为中心的架构,但是设计者仍然是数据模型/贫血领域模型的思考方式,把大量领域逻辑放置在了万能的 Service 中,让领域概念隐藏在了冗长的过程代码中,无法享受到 DDD 带来的收益。
最后总结下本文想要强调的要点:
- 领域模型和领域概念一一对应
- 领域模型和实现关联,也和通用语言关联。刻意使用通用语言沟通以验证模型是否合理
- 演示了一种设计领域模型的步骤
- 构造块类型不是最重要的,领域模型本身更加重要
- 更多的使用可以表达业务含义的值对象和临时值对象
- 聚合是一种设计,需要方法权衡
- 使用 Repository、Factory 获取和创建领域模型是应用层的职责,领域层应该关注在表达领域概念