领域驱动设计-使用案例

Mr.LR2024年5月5日
大约 7 分钟

使用案例

上一讲,我们介绍了DDD领域驱动一般的代码分层结构。本文以一个支付功能为案例,讲述如实使用领域驱动的代码结构实现一个支付功能。

需求背景

假设XXX系统有一个支付功能,该功能支持系统所有业务的支付操作,并且支持支付单取消,重新支付等功能。

大概流程如下:

image-20250205220424603

将每一个流程再细化

业务系统触发支付操作:可以是同步接口,也可以是mq异步交互,也可以将支付失败的订单重新支付。

创建支付单:接受业务系统的请求,保持待支付的支付单信息。

基础信息封装:用户信息,账户信息,基础业务数据的封装。

基础校验:用户校验,账户校验,金额校验,日期校验等。

数据组装:调用银行接口时,需要的各种报文信息。

领域对象整理

首先应用层的领域对象:同步支付功能,异步支付功能,重新支付功能。

领域层:基础信息封装,用户信息及校验,账户信息及校验,报文组装调用银行,金额校验,日期校验,业务个性化校验。领域数据仓。

下面我们就来看一下这些领域对象是怎么得来的?

  1. 设计实体

    大多数情况下,领域模型的业务实体与微服务的数据库实体是一一对应的。但某些领域模型的实体在微服务设计时,可能会被设计为多个数据实体,或者实体的某些属性被设计为值对象。

    我们分析用户信息时,还需要有证件号,用户电话等,它们被聚合根引用,不容易在领域建模时发现,我们需要在微服务设计过程中识别和设计出来。

    实体类放在领域层的 Entity 目录结构下。

  2. 找出聚合根

    聚合根来源于领域模型,在这里我们分别找出用户信息聚合根和账户信息聚合根。

    聚合根是一种特殊的实体,它有自己的属性和方法。聚合根可以实现聚合之间的对象引用,还可以引用聚合内的所有实体。聚合根类放在代码模型的 Entity 目录结构下。聚合根有自己的实现方法,比如生成客户编码,新增和修改客户信息等方法。

  3. 设计领域服务

    如果一个业务动作或行为跨多个实体,我们就需要设计领域服务。领域服务通过对多个实体和实体方法进行组合,完成核心业务逻辑。你可以认为领域服务是位于实体方法之上和应用服务之下的一层业务逻辑。

    按照严格分层架构层的依赖关系,如果实体的方法需要暴露给应用层,它需要封装成领域服务后才可以被应用服务调用。所以如果有的实体方法需要被前端应用调用,我们会将它封装成领域服务,然后再封装为应用服务。

    用户信息的查询及校验,账户信息的查询和校验,被封装成两个领域服务。在调用时给应用服务暴漏。并且这两个领域服务会被多次调用。界面展示应用层,支付应用层都会调用。

    领域服务类放在领域层的 Service 目录结构下。

  4. 设计仓储

    每一个聚合都有一个仓储,仓储主要用来完成数据查询和持久化操作。仓储包括仓储的接口和仓储实现,通过依赖倒置实现应用业务逻辑与数据库资源逻辑的解耦。

    仓储代码放在领域层的 Repository 目录结构下。这里由于我们仅对订单进行相关数据库操作。因此只创建订单的仓储。

领域对象与微服务代码对象的映射

我们整理出应用层,领域层的领域对象后,就可以分析出下图的代码映射关系了。

image-20250205231231383

最终的代码结构如下

image-20250205232323288

应用层代码实现

这里以一个伪代码举例

同步支付

接口层

    @RequestMapping("/pay")
    public void pay() {
        pamentApplication.pay();
    }

应用层

public class PamentApplication {
    
    public void pay(){
        // 用户校验
        personCheckService.check();
        // 账户校验
        accountCheckService.check();
        // 金额校验
        amountCheckService.check();
        // 创建订单
        payOrderService.create();
        // 基础数据组装
        baseBuildService.build();
        // 访问银行
        payBankService.push();
    }
}

也就是我们在领域层,实现每一个领域具体的逻辑代码,在应用层,负责编排领域方法执行的先后顺序。

重新支付

接口层

@RequestMapping("/retryPay")
public void retryPay(){
    pamentRetryApplication.retryPay();
}

应用层

public void retryPay(){
    // 查询失败订单
    payOrderService.query
    // 金额校验
    amountCheckService.check();
    // 基础数据组装
    baseBuildService.build();
    // 访问银行
    payBankService.push();
}

通过重新支付,我们也能看出来,有些领域方法可以直接复用,有些领域方法可能在重新支付时已经不需要了。

最后感悟

在不了解DDD的情况下,我们在接到这个需求时,很可能就按照传统的MVC架构去实现支付功能,即在一个service中,把校验,数据组装,访问银行,落库等逻辑写在一个类中,这样写虽然能快速的实现我们的需求,但是增加的日后代码的维护难度,可能别人在阅读这段代码时,都不明确支付功能具体有哪些校验。并且在重新支付,异步支付等功能中,会存在大量重复的代码。例如本案例,用户校验,账户校验等领域一定会在多个场景复用。

但是在基于DDD的设计原则下,我们接到需求后,首先对功能点进行领域拆分。即使很简单的校验也需要拆分。这样一方面可以增加开发对业务的理解,通过对业务领域的深入理解来建模系统。这样可以确保系统更好地贴合实际业务需求,避免开发过程中与业务脱节。

根据DDD设计原则写代码,可以提高代码的可维护性,DDD强调清晰的领域边界和聚合根的概念,避免了系统内部的混乱和复杂性。清晰的模块化设计使得系统更易于维护和扩展。例如本案例的用户领域,我们本次可能仅实现查用户名称的功能,但是可能后续需求会针对增强用户的相关校验,例如引入用户的征信信息,地址,电话等。我们在后续的需求中就可以针对性对用户领域做分析。而不需要过度考虑支付整体的功能。也就是我们修改用户领域,不需要考虑账户,金额,订单创建等功能。

本案例也只是针对一个简单的功能做领域建模,如果我们的功能很庞大,甚至可以根据领域划分去拆分服务。例如单独拆分出应用服务,用户服务,账户服务,订单服务等。其中应用服务负责整体功能的任务编排,以及一些个性化的逻辑,用户,账户,订单服务,负责自己领域内的功能。也就是在划分微服务的边界时,可以借助领域模型来指导微服务的拆分。每个微服务通常围绕一个特定的业务领域进行组织,符合DDD的原则。