在DDD中建立领域模型

  • Post author:
  • Post category:其他


在前文

《当我们谈论DDD时我们在谈论什么》

中我们讨论了DDD的战略设计和战术设计。在本文中我们将继续探讨领域模型。




用领域模型表达领域概念

在实际项目中,模型设计者往往过早陷入具体构造块类型的识别,比如实体、聚合、领域服务,而忽略了领域模型表达领域概念的目的。我们应该基于领域概念设计领域模型,然后再采用合适的模式降低领域模型的复杂度,进一步增加领域模型的表达能力。

领域模型的作用,一方面是关联代码实现,一方面是关联通用语言。我们对于模型和实现的关联轻车熟路,但是对于语言和模型关联往往有待提升。在沟通中刻意使用通用语言可以帮助我们验证模型的合理性。

我们以一个题目为例,方便后续讨论。

活动平台提供用户参与活动得到奖品的功能,吸引用户及潜在用户参与,以达到拉新、促活、引流的目的。

运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的开始时间和结束时间、参与资格、权益。

用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合资格的用户就会获得一份奖品。权益可能是信用卡积分,也可能是优惠券。

参与资格可能是:一天内注册的用户、VIP用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。

活动平台提供用户参与活动得到奖品的功能,吸引用户及潜在用户参与,以达到拉新、促活、引流的目的。

运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的开始时间和结束时间、参与资格、权益。

用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合资格的用户就会获得一份奖品。权益可能是信用卡积分,也可能是优惠券。

参与资格可能是:一天内注册的用户、VIP用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。

对于一个活动,一个用户只能参加一次。对于一个活动,一个用户只能参加一次。




建立模型

第一步是根据需求分析模型。

我们可以找到以下概念:活动、参与资格、权益。其中参与资格是扩展点。

对于需求「一个用户只能参加一次活动的」,需要记录用户是否参与过活动,所以需要「活动参与记录」的概念。



参与活动的结果可能有2种:符合参与资格则返回权益,不符合则返回「不符合」。所以我们用了Optional<权益>类型。

我们到这里只识别了各种名词,需要走查用例,寻找缺失的概念:

  • 用例1,运营人员可以创建并修改活动
  • 用例2,用户可以参与活动并获得权益

对于用例1,创建和修改活动,目前模型已经满足了需求。

对于用例2,这里有一个模型之外的规则:「一个用户只能参加一次活动」。这是所有活动都需要遵守的规则,我们将其称为「活动通用规则」。

虽然只是一个很简单的逻辑,但是提取「活动通用规则」这个概念非常有用。如果没有这个概念,那么每次去描述这个概念,只能用「一个用户只能参加一次活动的规则」去表示,非常繁琐;也让概念没有安身之地,容易被随便放到万能的Service中。

我们将其加入领域模型。





PS:这里故意省略了参与资格的实现。

我们没有把「活动通用规则」放到活动概念里,一部分原因是这个判断逻辑不需要具体活动的信息。




使用通用语言验证模型

有了领域模型,就有了通用语言。使用通用语言重新描述需求,并尽量在沟通中使用通用语言。

运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的开始时间和结束时间

时间段

、参与资格、权益。

用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合资格

参与资格

的用户就会获得一份奖品

权益

。权益可能是信用卡积分,也可能是优惠券。

参与资格可能是:一天内注册的用户、VIP用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。

运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的

开始时间和结束时间


时间段

、参与资格、权益。

用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合

资格


参与资格

的用户就会获得一份

奖品


权益

。权益可能是信用卡积分,也可能是优惠券。

参与资格可能是:一天内注册的用户、VIP用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。

**同时有「活动通用规则」:**对于一个活动,一个用户只能参加一次。**同时有「活动通用规则」:**对于一个活动,一个用户只能参加一次。

这里去掉了「开始时间」、「资格」、「奖品」等模糊不清晰的描述。使用基于领域模型的语言,让需求描述清晰没有歧义。

到目前为止,主要的领域模型都已经分析出来。所有的模型都对应明确的领域概念,不多也不少。




识别构造块类型

在分析了领域模型后,我们再来分析构造块类型。

我们通过是否有状态来做区分。

首先识别有状态的对象:活动、各种参与资格、权益、活动参与记录、用户。一般有状态的对象都是事物,对应的构造块类型也就是实体或者值对象。

其次判断其状态是否会改变:

  • 活动会被修改,所以状态会被改变;
  • 参与资格会被修改,但是参与资格从属于活动,修改后可以直接使用新的对象替换旧的,所以可以设计成状态不变;
  • 权益和参与资格一样,也可以设计成状态不变;
  • 活动参与记录,状态可能发生变化;
  • 用户在这个模型中只是临时存在,状态不会变化。

状态会改变的是实体,包括活动和活动参与记录;状态不变的就是值对象,包括参与资格、权益和用户。

最后剩下的就是无状态的对象:活动通用规则。对应的构造块类型是领域服务。

这里的无状态对象大都可以转化成有状态的对象,例如活动通用规则,可以将方法参数的Optional<活动参与记录>变成成员变量。只是这里我们选择了无状态的设计方法。

由于领域服务没有状态,所以可以在应用启动时就创建出来,也可以在使用时才创建。



经过分析,我们的领域模型都有了类型。






设计聚合

首先识别生命周期长的领域对象:在一个操作中被创建出来,操作结束后仍会被其他操作使用的对象。活动、参与资格、权益和活动参与记录都是生命周期长的对象。

其他有状态的对象都是临时对象:在一个操作中被创建出来,操作结束后就不会再被使用。模型中的用户,在一次操作中从其他服务获取,使用后即被丢弃。

这里我们总结下各构造块类型的特点:


实体

值对象

领域服务
是否有状态 有且状态可变 有且状态不可变
生命周期 长或者短 长短均可

在生命周期的长的对象中,我们要设计聚合。聚合作为操作单元,主要解决以下几个问题:

  • 整个模型往往庞大复杂,为了降低知识负载,需要将其分解成多个小且简单的模型,划分清晰的边界
  • 部分模型对象之间存在一致性规则,例如需要被一起删除,所以需要放在一个操作中
  • 多个用户可能会并发操作模型,为了避免相互干扰,需要让操作单元尽可能小
  • 对于操作单元,需要将其频繁加载到内存中,如果单元过大,往往不能满足性能要求

根据对业务的了解,活动及参与资格、权益都是一起被创建和修改,可以放在一个聚合里;活动和活动参与记录之间没有一致性规则,可以分开;因为活动参与记录数量会很多,如果和活动在一个聚合中,会降低性能。

所以我们将活动、参与资格、权益设计成一个聚合,而活动参与记录作为一个单独的聚合。而活动和活动参与记录分别作为这两个聚合的聚合根。对应的,聚合都会配备其专属的Repository。



同时加上遍历方向箭头。由于活动是聚合根,从活动可以遍历到聚合内部的参与资格和权益。另外查询活动参与记录,可以通过其Repository,所以没有活动到活动参与记录的箭头。

由于我们将活动和活动参与记录之间划分成不同聚合,那他们之间的关联将使用聚合的ID来关联,而不是聚合本身。

PS:如果使用了关联对象,遍历方向也可以是从活动到活动参与记录。




如何使用领域模型

领域模型已经建立完毕,我们来看如何使用领域模型以满足用例。

运营人员创建活动基本信息及其关联的参与资格和权益。领域模型的客户(一般来说是应用服务),使用运营人员输入的参数构造出活动对象,再利用Repository将其保存。

运营人员修改活动。应用服务利用Repository获取需要修改的活动,再根据运营人员提供的参数修改活动,最后利用Repository保存活动对象。

用户参与活动。应用服务:

  1. 使用活动通用规则判断用户是否可以参加。由于活动通用规则需要用到活动参与记录,因此应用服务会使用Repository获取活动参与记录;
  2. 如果可以参加,则执行活动的参与活动方法获得结果。这需要利用Repository获取用户参与的活动,并构造用户对象(可能需要调用用户服务获取用户信息,但是领域层并不关心这些逻辑);
  3. 如果结果是获得权益,则创建活动参与记录,并利用Repository保存。

考虑到并发情况,应用服务可以在第1步前加锁,并在第3步后释放锁。




再次思考

  • 配置和参与活动可否是两个模型?

在实现运营人员配置活动的用例过程中,我们会发现可能找到了一个隐藏的领域概念,将输入的参数转换成领域模型的逻辑有些枯燥和复杂,同样将领域模型和数据库的数据模型之间转换也如此。输入参数和数据模型都是只是扁平的数据数据,没有继承结构。如果使用另外一种面向数据的模型,也许这些用例实现起来会简单得多。

每个模型都是为了解决某个问题。这里运营人员配置和用户参与活动是不同的问题,如果用一个模型来解决这两个问题,可能会有些吃力。那么干脆设计成两个模型,使用限界上下文的概念将这两个模型限定在各自的上下文中,也许更加合理。两个模型可以共享同一份数据库数据,并加上一段(非领域层的)逻辑用于模型之间的转换。

这实际上是一种配置-使用模式。在配置阶段,注重配置类型和参数、审批等;在使用阶段,注重逻辑计算和性能。

  • 活动参与记录是否可以建模成领域事件?

活动参与记录实际上是不可变的,可以将其设计为领域事件。

  • 用户参与活动的用例里,逻辑复杂,有泄漏领域概念的嫌疑?

如果发现应用服务里逻辑变得复杂,可能意味着我们找到了一个隐藏的领域概念。我们可以定义一个「用户参与活动逻辑」的概念:如果用户通过了活动通用规则的判断,则可以参与活动。将其加入模型和通用语言中,在沟通中验证此概念是否合理。




总结

很多项目虽然也使用了以领域模型为中心的架构,但是设计者仍然是数据模型/贫血领域模型的思考方式,把大量领域逻辑放置在了万能的Service中,让领域概念隐藏在了冗长的过程代码中,无法享受到DDD带来的收益。

最后总结下本文想要强调的要点:

  • 领域模型和领域概念一一对应
  • 领域模型和实现关联,也和通用语言关联。刻意使用通用语言沟通以验证模型是否合理
  • 演示了一种设计领域模型的步骤
  • 构造块类型不是最重要的,领域模型本身更加重要
  • 更多的使用可以表达业务含义的值对象和临时值对象
  • 聚合是一种设计,需要方法权衡
  • 使用Repository、Factory获取和创建领域模型是应用层的职责,领域层应该关注在表达领域概念

文/Thoughtworks 祁兮

原文链接:

https://insights.thoughtworks.cn/how-to-build-domain-model-ddd/



版权声明:本文为toafu原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。