捂脸斋

江山风月 🌘 本无常主 🌈 闲者便是主人 🍃🍃🍃

详解领域驱动设计

作为一名合格的软件工程师,在学习DDD之前,我们要明确以下几条认知。

  • DDD是一套面向对象的建模方法论,它本身并不涉及编程语言、前端、后端。
  • DDD四层架构并不等同于DDD,它只是一种基于DDD思想的、适用于后端的软件实现方式。
  • 学习DDD的思想和方法论并不意味着我们会采用它的四层架构。

对后端工程师而言,还有两条。

  • 相比传统的MVC架构,DDD四层架构更加复杂,需要更多的Coding时间。
  • DDD四层架构能够更好地适应大型项目,在中小型项目中性价比并不高。

如果让我用简单的语言来概括DDD的作用,应该是这样的。

  • 能够帮助我们更好地进行面向对象建模,让我们的软件架构更加稳定。虽然没有外力的系统一定是熵增的,但是DDD很好地抑制了熵增的速率。
  • 让我们更加专注于业务本身,和底层的实现解绑。
  • 让代码更加优雅,也就是高内聚、低耦合。延伸出业务逻辑更加聚合、扩展性更高、更加能迎合变动。

so,Let’s code better

阅读建议

  • 《01》,for 后端,前端同学可以简单了解。
  • 《02》,for 前端、后端。
  • 《03》,for 后端,前端同学可以简单了解。
  • 《04》,for 后端,前端可以可以适当了解。

参考文献

《实现领域驱动设计》

Domain Primitive

仓储模式

复杂度应对之道

极客时间-DDD实战课

DDD分层架构总览

用户接口层 Interface

用户接口层负责向用户显示信息和解释用户指令。这里的用户可能是:用户、程序、自动化测试和批处理脚本等等。

应用层 Application

  • 应用层是很薄的一层,理论上不应该有业务规则或逻辑,主要面向用例和流程相关的操作。
  • 应用层可以协调不同的领域服务和领域对象。
  • 应用层是微服务之间交互的通道,它可以调用其它微服务的应用服务,完成微服务之间的服务组合和编排。
  • 不要将本该放在领域层的业务逻辑放到应用层中实现。庞大的应用层会使领域模型失焦,时间一长微服务就会演化为传统的三层架构,业务逻辑会变得混乱。
  • 应用服务还可以进行安全认证、权限校验、事务控制、发送或订阅领域事件等。

领域层 Domain

  • 体现领域模型的业务能力,它用来表达业务概念、业务状态和业务规则。
  • 实现核心业务逻辑,通过各种校验手段保证业务的正确性。
  • 包含聚合根、实体、值对象、领域服务等。
  • 领域模型的业务逻辑主要是由实体和领域服务来实现的,其中实体会采用充血模型来实现所有与之相关的业务功能。

基础层 Infrastructure

  • 贯穿所有层,为其它各层提供通用的技术和基础服务。包括第三方工具、驱动、消息中间件、网关、文件、缓存以及数据库等。

  • 采用依赖倒置设计,封装基础资源服务,实现应用层、领域层与基础层的解耦,降低外部资源变化对应用的影响。

设计原则

  1. 面向领域(业务)建模,而不是面向数据库建模。

    在之前,我们拿到一个需求之后,首先会设计表结构,然后定义实体类(贫血模式,没有业务逻辑,就是一个数据容器),然后在一个个service中写若干个脚本型方法。这其实是一种面向过程的编码,我们的业务逻辑分散在一个个脚本中。

  2. 核心代码应该在实体、值对象中。

  3. 实体、值对象、领域服务要足够内聚。

  4. 领域服务是一种妥协。

Entity-ValueObject-DomainSevice

实体 Entity

拥有唯一标识符,且标识符在历经各种状态变更后仍能保持一致。对这些对象而言,重要的不是其属性,而是其延续性和标识,对象的延续性和标识会跨越甚至超出软件的生命周期。我们把这样的对象称为实体。

实体类通常采用充血模型,与这个实体相关的所有业务逻辑都在实体类的方法中实现,跨多个实体的领域逻辑则在领域服务中实现

在设计实体时,不要过分关注数据库、表、列以及对象映射上,要从面向数据库设计转变成面向领域、对象设计。

  • 唯一性
    • 必须有唯一标识,equals方法根据这个唯一标识来判断。
  • 高内聚,低耦合
    • 不能依赖第三方服务、数据库。
    • 属性、方法的入参和返回值必须是值对象。
    • 不要把细节暴露给调用方。
  • 自封闭性
    • 必须提供明确的构造方法,这里的构造方法是一个广义的概念,即你可以通过new或静态方法创建对象。构造方法往往有很多校验逻辑和初始化动作。但是只要实体被创建出来,那么它就是完整的。
    • 和上一条类似,实体的构造逻辑必须掌握在自己手上。
    • 禁止使用空参构造器。
    • 禁止开放setter。一个高内聚的实体,应该不会出现让外部修改属性的情况。属性的修改一定是在实体内部(实体的公共方法)发生的。
  • 不要为了充血而充血。
  • 不同上下文中的实体,它们的业务逻辑不要放在一起。
    • 比如订单上下文中的订单A和物流上下文中的订单B。A是一个复杂的业务实体,有很多方法;B可能仅仅是一个值对象。A和B的方法一定是分开的。
  • 一个实体可能对应 0 个、1 个或者多个数据库持久化对象。

值对象 Value Object

将基本数据类型、数据容器封装成带有明确业务含义、能够进行自我验证和行为的对象。

值对象是最小的业务单元。 

如果值对象的方法会修改值对象的某个属性,那么这个方法一定会返回新的值对象。

值对象也必须具备高内聚|自封闭性

关于值对象的更多解释,可以参考DP模型。Domain Primitive

领域服务 DomainService

什么是领域服务

关于领域服务,没有准确的定义。但是我们可以总结其特点。

  1. 领域服务是实体的补充,是一种妥协。
  2. 一些不属于实体的、但是又不能放到应用服务中的代码。

哪些操作可能属于领域服务

  1. 跨多个实体的操作。比如转账。
  2. 和外部系统发生交互的操作。比如调用第三方SDK。

领域服务的一些约束

  1. 不能在领域服务中写太多业务逻辑。
  2. 禁止在领域服务中调用dao和repo。

Repo And Factory

仓储 Repository

原则上,只有聚合根才需要创建仓储。尽量不要给每个实体创建一个仓储。聚合根内部实体的生命周期和聚合根是一致的。

仓储最好只有findById()和save()两个方法。

工厂 Factory

这里的工厂并不是设计模式中的工厂,而是领域模型中的一个概念。目的是将创建实体的逻辑封装起来,满足自封闭性和内聚的特性。

1
2
3
4
5
6
7
8
9
10
/**
* 这里用到了DP模型的概念,可以参考上一期:Entity-ValueObject-DeomainService
*/
public static Product create(ProductName name, ProductDesc desc, Price price) {
return new Product(name, desc, price);
}

private Product(ProductName name, ProductDesc desc, Price price) {
// this.name = ...;
}

Example-规范-总结

标准DDD项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|-- ink.wulian.simple.ddd
|-- application
|-- service
|-- OrderAppService.I
|-- OrderQueryAppService.I
|-- impl
|-- OrderAppServiceImpl
|-- OrderQueryAppServiceImpl
|-- infrastructure
|-- config
|-- SwaggerConfig
|-- filter
|-- AccessFilter
|-- helper
|-- CollectionHelper
|-- repository
|-- po
|-- OrderPO
|-- OrderHistoryPO
|-- assembler
|-- OrderAssembler
|-- mysql
|-- OrderMapper.I
|-- mongo
|-- OrderHistoryDao
|-- OrderRepoImpl
|-- domain
|-- object
|-- entity
|-- Order
|-- OrderHistory
|-- value
|-- OrderOrigin.E
|-- OrderStatus.E
|-- repository
|-- OrderRepo.I
|-- OrderHistoryRepo.I
|-- svc
|-- OrderSvc
|-- event
|-- listener
|-- OrderEventListener
|-- OrderPlacedEvent
|-- message
|-- OrderSucceededMessage
|-- interfaces
|-- controller
|-- api
|-- OrderController
|-- OrderQueryController
|-- rpc
|-- OrderController
|-- assembler
|-- OrderAssembler
|-- OrderHistoryAssembler
|-- co
|-- PlaceOrder
|-- PayOrder
|-- vo
|-- Page
|-- OrderBaseVO
|-- OrderParticularsVO
|-- OrderDetailVO
|-- response
|-- ResultBox
|-- ResultEnum
|-- ServiceException
| Application

关于文件后缀的说明

  • 如无后缀,则此行是一个文件或一个文件夹,根据有无下级自行判断。
  • .I 此文件是一个接口。
  • .E 此文件是一个枚举。
  • .I 且有下级目录,此行是一个接口,接口中定义了静态类。

补充

  • 如果appService返回VO,则interfaces.assembler可以放到application层。

各层的编码规范

application.service
  1. 命名必须遵循规范*AppService|*AppSvc|*AppServiceImpl|*AppSvcImpl
  2. 接口中的每个方法都必须有详细的注释;每个参数必须添加@NonNull,且参数类型一定是interfaces.co
  3. 非常建议定义一个查询专用的类。因为查询代码一般比较庞大,这样做可以和业务代码做区分。
  4. 如果你拆分了查询类,但是发生了查询类和业务类拥有重复代码的情况,一定是你的分层有问题。
  5. 此层中,方法的返回值不做强制要求(领域对象或VO),但是最好统一。
  6. UserProfile必须在方法的参数中传递,禁止直接注入此层。
  7. 类似Request|Response等上下文,一定不会出现且禁止出现在此层。如果有,证明你的分层有问题。
  8. 此层应该不会出现参数校验的代码。
  9. @Transactional(rollbackFor = Exception.class)出现在这一层。
  10. 可调用范围:Entity|ValueObject|DomainService|Repository|EventPublisher
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
* 下单
* @param co
* @Param currentUser 当前用户上下文
* @Return 订单领域对象
*/
@Transactional(rollbackFor = Exception.class)
@Override
public Order placeOrder(@NonNull PlaceOrderCO co, @NonNull UserProfile currentUser) {
// log;

// check permission;

// 把co封装成若干个值对象,值对象的构造函数中包含了校验逻辑。
// 这里涉及到DP模型的概念,可以查看上一期中的链接。
// 这里应该不会出现参数校验的代码,即 co.checkParams() or OrderValidator.checkPlaceOrder(co);
OrderAmount amount = new OrderAmount(co.getAmount());
Currency currency = new Currency(co.getCurrency());

// place order;
Order order = Order.place(co.getGid(), co.getUid(), amount, currency);

// create history;
// 严格来说,这里的order和上面的order不在同一个上下文中,这里的order仅仅是一个值对象。
OrderHistory history = OrderHistory.create(OperateAction.create, co.getUid(), order);

// save repo;
orderRepo.save(order);
historyRepo.save(history);

// publish event;
// 严格来说,这里的order也不是原来的order。
eventPublisher.publishEvent(new OrderCreatedEvent(order));

// log;

return order;
}

/**
* 取消订单
* @param co
* @Param currentUser 当前用户上下文
*/
@Transactional(rollbackFor = Exception.class)
@Override
public void cancelOrder(@NonNull CancelOrder co, @NonNull UserProfile currentUser) {
// log;

// 大部分方法的第一行,应该都是根据id获取领域对象。
// lock order;
Order order = orderRepo.lock(co.getOrderId());

// check permission;

// cancel order;
order.cancel();

// create history;
// 严格来说,这里的order和上面的order不在同一个上下文中,这里的order仅仅是一个值对象。
OrderHistory history = OrderHistory.create(OperateAction.create, co.getUid(), order);

// save repo;
orderRepo.save(order);
historyRepo.save(history);

// publish event;
// 严格来说,这里的order也不是原来的order。
eventPublisher.publishEvent(new OrderCanceledEvent(order));

// log;
}
domain.object.entity
  1. 为了简洁,命名不必以DO结尾,但是其他类必须以PO|VO|CO|CMD结尾。
  2. 数据库层的信息不应该出现在这里,比如@Entity|@Id|@IdType等注解。
  3. 领域对象应该采用充血模式。
  4. 必须有明确的构造方式,且构造完成之后,该实体就是完整的。
  5. 禁止公开空参构造。
  6. 一些初始值、常数、默认值,也应该定义在领域对象中。
  7. 实体不依赖第三方和数据库。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
* package: domain.object.entity
*/
public final class Order {

/**
* empty gid;
*/
public static final String EMPTY_GID = StrUtil.EMPTY;

/**
* the default valid time of order.
* 60min latter, order will be auto cancelled.
*/
public static final long AUTO_CANCEL_MINUTES = 60L;

private Long id;

private LocalDateTime insertTime;

private String uid;

private String gid;

private OrderPayment orderPayment;

private OrderStatus status;

/**
* if this order.status is canceled.
*/
public boolean canceled() {
return status != null && status == OrderStatus.canceled;
}

/**
* place a new order.
*/
public static Order place(@NonNull String uid, @NonNull OrderAmount amount, @NonNull Currency currency, @NonNull OrderOrigin origin) {
// new and set;
Order order = new Order();
// set;
return order;
}

/**
* confirm payment.
* status and valid will be checked before.
*/
public void confirmPayment(@NonNull String paymentIntentId) {
// check status;
// check valid;
status = OrderStatus.waitForPayAction;
OrderPayment newOrderPayment = new OrderPayment();
newOrderPayment.setPaymentIntentId(paymentIntentId);
orderPayment = newOrderPayment;
}

/**
* success.
* status will be reset to success and payment method will be assignment.
* status will be checked before.
*/
public void success(@NonNull String paymentMethodType) {
if (!waitForPayAction()) {
log.warn("[success] order status not matches, order.id: {}, order.status: {}", id, status);
throw new ServiceException(ResultEnum.orderIsNotWaitingForPayment);
}
status = OrderStatus.paidSuccess;
orderPayment.setPaymentMethod(paymentMethodType);
}

/**
* cancel order.
* status will be checked before.
*/
public void cancel() {
if (paidSuccess() || canceled()) {
log.warn("[cancel] order can not be cancelled, status: {}", status);
throw new ServiceException(ResultEnum.paidSuccessOrCancelledOrderCanNotBeCancelled);
}
status = OrderStatus.canceled;
}

}
infrastructure.repository.po|domain.object.entity|domain.object.value
  1. 不要陷入PO和实体一一对应的思维误区。一个PO可以对应对个实体,一个实体也可以对应多个PO。
  2. 例如地区、订单明细这些值对象,不要在domain.object.value.Address|domain.object.value.OrderParticulars声明关系字段(userId, orderId),而是应该在PO中声明。因为脱离了聚合根,这两个值对象没有任何意义。
  3. po不是必须的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/*
* package: infrastructure.repository.po
*/
public class OrderParticularsPO {

/**
* 唯一索引。
* 描述这条明细属于哪个订单。
*/
private Long orderId;

private JSONObject metadata;

/**
* 国家和省份,定义成两个字段,方便db搜索。
*/
private String country;

private String province;

}

/**
* package: domain.object.value
* 注意在值对象中,orderId被忽略了。
*/
public class OrderParticulars {

private JSONObject metadata;

private Address address;

}

/**
* package: domain.object.value
*/
public class Address {

private String country;

private String province;

}
domain.svc
  1. 领域服务中的方法,其入参应该是领域对象或者值对象(DP模型)。
  2. 领域服务是对实体的补充和妥协,和实体是平级的。不可以操作仓储。
  3. 参数校验逻辑和状态值校验逻辑分散在不同的领域对象中。这一点和之前的事务脚本非常不同。在事务脚本中,我们习惯于在方法的最上面写一些卫语句GuardClause,达到Fail First但实际上只有最下层的领域对象才拥有校验逻辑
  4. 可调用范围:Entity|ValueObject

其他

  1. co|application.XXXAppSvc.xMethod()|domain.svc.XXXSvc.xMethod()等必须以业务、功能命名,禁止使用insert|create|update等。

  2. 禁止将null作为方法的返回值,如果返回值可能为null,请使用Optional<T>

  3. 禁止将null作为参数传递给某个方法。建议在声明方法时添加@NonNull

  4. 禁止使用@AllArgsConstructor|@NoArgsConstructor,可以用IDE帮助生成全参构造。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @AllArgsConstructor
    class Address {
    String country;
    String province;
    }

    /**
    * 在V2中,我调整了两个字段的顺序,这是一个很常见、并且主观上认为没有任何影响的改动。
    */
    @AllArgsConstructor
    class AddressV2 {
    String province;
    String country;
    }

    main() {
    Address adds = new Address("China", "ShangHai");
    // 意料之外的传值错误。
    // `China`传给了`province`, `ShangHai`传给了`country`.
    AddressV2 addsV2 = new AddressV2("China", "ShangHai");
    }
  5. assembler可以根据实际情况拆分或合并,灵活度比较高。

  6. event|message的命名,必须使用过去式。

尾巴

DDD是一套适应大型项目的方法论,可以有效地减缓我们代码的腐败。但是DDD的项目结构比较复杂,编码规范也比较多,在面对中小型项目时,可能会稍显笨重。我们不一定会把DDD运用到项目中,但是掌握这样的编程思想会让我们在面对软件复杂化时更加游刃有余。