领域驱动设计实战-DDD
领域驱动(DDD,Domain Driven Design)为软件设计提供了一套完整的理论指导和落地实践,通过战略设计和战术设计,将技术实现与业务逻辑分离,来应对复杂的软件系统。本系列文章准备以实战的角度来介绍 DDD,首先编写领域驱动的代码模型,然后再基于代码模型,引入 DDD 的各项概念,先介绍战术设计,再介绍战略设计。
> DDD 实战1 - 基础代码模型
> DDD 实战2 - 集成限界上下文(Rest & Dubbo)
> DDD 实战3 - 集成限界上下文(消息模式)
> DDD 实战4 - 领域事件的设计与使用
> DDD 实战5 - 实体与值对象
> DDD 实战6 - 聚合的设计
> DDD 实战7 - 领域工厂与领域资源库
> DDD 实战8 - 领域服务与应用服务
> DDD 实战9 - 架构设计
> DDD 实战10 - 战略设计
在 DDD 中,共有四层(领域层、应用层、用户接口层、基础设施层),其层级实际上是环状架构。如上图所示。根据整洁架构思想,在上述环状架构中,越往内层,代码越稳定,其代码不应该受外界技术实现的变动而变动,所以依赖关系是:外层依赖内层
。按照这个依赖原则,DDD 代码模块依赖关系如下:
- 领域层(domain):位于最内层,不依赖其他任何层;
- 应用层(application):仅依赖领域层;
- 用户接口层(interfaces):依赖应用层和领域层;
- 基础设施层(infrastructure):依赖应用层和领域层;
- 启动模块(starter):依赖用户接口层和基础设施层,对整个项目进行启动。
注意:interfaces 和 infrastructure 位于同一个换上,二者没有依赖关系。
DDD 各层职责
领域模型层 domain
包括实体、值对象、领域工厂、领域服务(处理本聚合内跨实体操作)、资源库接口、自定义异常等
应用服务层 application
跨聚合的服务编排,仅编排聚合根。包括:应用服务等
用户接口层 interfaces
本应用的所有流量入口。包括三部分:
- web 入口的实现:包括 controller、DTO 定义、DTO 转化类
- 消息监听者(消费者):包括 XxxListener
- RPC 接口的实现:比如在使用 Dubbo 时,我们的服务需要开放 Dubbo 服务给第三方,此时需要创建单独的模块包,例如 client 模块,包含 Dubbo 接口和 DTO,在用户接口层中,去做 client 中接口的实现以及 DTO 转化类
基础设施层 infrastructure
本应用的所有流量出口。包括:
- 资源库接口的实现
- 数据库操作接口、数据库实现(如果使用mybatis,则包含 resource/*.xml)、数据库对象 DO、DO 转化类
- 中间件的实现、文件系统实现、缓存实现、消息实现 等
- 第三方服务接口的实现
基于 DDD 开发订单中心
需求:基于 DDD 开发一个订单中心,实现下订单、查询订单等功能
代码:https://github.com/zhaojigang/ordercenter
ordercenter 根模块
├── order-application 应用模块
├── order-domain 领域模块
├── order-infrastructure 基础设施模块
├── order-interfaces 用户接口模块
├── order-starter 启动模块
└── pom.xml 根模块
领域层代码模型
包依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
引入 spring-boot-autoconfigure:2.4.2,在领域工厂中需要用到 Spring 注解
DDD 标识注解 common.ddd.AggregateRoot
/**
* 标注一个实体是聚合根
*/
@Documented
@Retention(SOURCE)
@Target(TYPE)
public @interface AggregateRoot {
}
自定义异常 common.exception.OrderException
/**
* 自定义异常
*/
@Data
public class OrderException extends RuntimeException {
private Integer code;
private String message;
public OrderException(Integer code, String message) {
this.code = code;
this.message = message;
}
}
将自定义异常放在领域层,因为 DDD 推荐使用充血模型,在领域实体、值对象或者领域服务中,也会做一些业务逻辑,在业务逻辑中,可以根据需要抛出自定义异常
资源库接口 io.study.order.repository.OrderRepository
/**
* 订单资源库接口
*/
public interface OrderRepository {
/**
* 保存订单
*
* @param order 订单
*/
void add(Order order);
/**
* 根据订单ID获取订单
* @param orderId
*/
Order orderOfId(OrderId orderId);
}
- 资源库接口放置在领域层,实现领域对象自持久化,同时实现依赖反转。
- 依赖反转:将依赖关系进行反转,假设 Order 要做自持久化,那么需要拿到资源库的实现 OrderRepositoryImpl 才行,那么 domain 包就要依赖 infrastructure 包,但是这不符合
外层依赖内层
的原则,所以需要进行依赖反转,由 infrastructure 包依赖 domain 包。实现依赖反转的方式就是在被依赖方中添加接口(例如,在 domain 包中添加 OrderRepository 接口),依赖包对接口进行实现(infrastructure 包中对 OrderRepository 进行实现),这样的好处是,domain 可以完全仅关注业务逻辑,不要关心具体技术细节,不用去关心,到底是存储到 mysql,还是 oracle,使用的数据库框架是 mybatis 还是 hibernate,技术细节的实现由 infrastructure 来完成,真正实现了业务逻辑和技术细节的分离- 资源库的命名推荐:对于资源库,推荐面向集合进行设计,即资源库的方法名采用与集合相似的方法名,例如,保存和更新是 add、addAll,删除时 remove、removeAll,查询是 xxxOfccc,例如 orderOfId,ordersOfCondition,复数使用 xxxs 的格式,而不是 xxxList 这样的格式
- 一个聚合具有一个资源库:比如订单聚合中,Order 主订单是聚合根,OrderItem 子订单是订单聚合中的一个普通实体,那么在订单聚合中只能存在 OrderRepository,不能存在 OrderItemRepository,OrderItem 的 CRUD 都要通过 OrderRepository 先获得 Order,再从 Order 中获取 List<OrderItem>,再做逻辑。这样的好处,
保证了聚合根值整个聚合的入口,对聚合内的其他实体和值对象的方访问,只能通过聚合根,保证了聚合的封装性
领域工厂 io.study.order.factory.OrderFactory
/**
* 订单工厂
*/
@Component
public class OrderFactory {
private static OrderRepository orderRepository;
@Autowired
public OrderFactory(OrderRepository repository) {
orderRepository = repository;
}
public static Order createOrder() {
return new Order(orderRepository);
}
}
工厂的作用:创建聚合。
工厂的好处:
- 创建复杂的聚合,简化客户端的使用。例如 Order 的创建需要注入资源库,订单创建后,可以直接发布订单创建事件。
- 可读性好(更加符合通用语言),比如 对于创建订单,createOrder 就比 new Order 的语义更加明确
- 更好的保证一致性,防止出错,假设创建两个主订单 Order,两个主订单下分别还要创建多个子订单 OrderItem,每个子订单中需要存储主订单的ID,如果由客户端来设置 OrderItem 中的主订单ID,可能会将A主订单的ID设置给B主订单下的子订单,可能出现数据不一致的问题,具体的示例见 《实现领域驱动》P183。
实体唯一标识 io.study.order.domain.OrderId
import lombok.Value;
/**
* 订单ID
*/
@Value
public class OrderId {
private Long id;
public static OrderId of(Long id) {
return new OrderId(id);
}
public void validId(){
if (id == null || id <= 0) {
throw new OrderException(400, "id 为空");
}
}
}
- 推荐使用强类型的对象作为实体的唯一标识,好处有两个:
a. 用来避免传参混乱,同时提升接口的可读性,例如 xxx(Long orderId, Long goodsId),假设上述接口第一个参数传了 goodsId,第二个传了 orderId,那么编译期是无法发现的,改为 xxx(OrderId orderId, GoodsId, goodsId) 即可避免,同时可读性也较高。
b. 唯一标识中会有一些其他行为方法,如果唯一标识使用弱类型,那么这些行为方法将会泄露在实体中- 唯一标识类是一个值对象,推荐值对象设置为不可变对象,使用 @lombok.Value 标注值对象,既可标识该对象为值对象,也可以是该类变为不可变类。例如,表示后的 OrderId 没有 setXxx 方法。
- 值对象的行为函数都是无副作用函数(即不能影响值对象本身的状态,例如 OrderId 对象被创建后,不能再使用 setXxx 修改其属性值),如果确实有属性需要变动,值对象需要整个换掉(例如,重新创建一个 OrderId 对象)
聚合根 io.study.order.domain.Order
/**
* 订单聚合根
*/
@Setter
@Getter
@AggregateRoot
public class Order {
/**
* 订单 ID
*/
private OrderId id;
/**
* 订单名称
*/
private String name;
/**
* 订单资源库
*/
private OrderRepository orderRepository;
protected Order(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
/**
* 创建订单
*
* @param order
*/
public void saveOrder(Order order) {
orderRepository.add(order);
}
public void setName(String name) {
if (name == null) {
throw new OrderException(400, "name 不能为空");
}
this.name = name;
}
public void setGoodsId(Long goodsId) {
if (goodsId == null) {
throw new OrderException(400, "goodsId 不能为空");
}
this.goodsId = goodsId;
}
public void setBuyQuality(Integer buyQuality) {
if (buyQuality == null) {
throw new OrderException(400, "buyQuality 不能为空");
}
this.buyQuality = buyQuality;
}
}
- 聚合根是一个特殊的实体,是整个聚合对外的使者,其他聚合与改聚合沟通的方式只能是通过聚合根
- 由于使用工厂来创建 Order,那么 Order 的构造器需要设置为 protected,防止外界直接使用进行创建
- 实体单个属性的校验需要在 setXxx 中完成自校验
- 实体是可变的、具有唯一标识,其唯一标识通常需要设计成强类型
- 聚合中的 XxxRepository 可以通过上述的工厂进行注入,也可以使用“双委派”机制,即提供类似方法:
createOrder(Order order, XxxRepository repository)
,然后应用层在调用该方法时,传入注入好的 repository 实例即可。但是这样的方式,提高了客户端使用的复杂性。
应用层代码模型
包依赖
<dependencies>
<dependency>
<groupId>io.study</groupId>
<artifactId>order-domain</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
应用服务 io.study.order.app.service.OrderAppService
/**
* 订单应用服务
*/
@Service
public class OrderAppService {
/**
* 创建一个订单
*
* @param order
*/
public void createOrder(Order order) {
/**
* 存储订单
*/
order.saveOrder(order);
/**
* 扣减库存
*/
}
}
应用服务用于服务编排,如上述先存储订单,然后再调用库存服务减库存。(库存服务属于第三方服务,第三方服务的集成见下一小节)
基础设施层代码模型
包依赖
<dependencies>
<!-- 领域模块 -->
<dependency>
<groupId>io.study</groupId>
<artifactId>order-domain</artifactId>
<version>${project.version}</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- mapstruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<scope>provided</scope>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
- mapstruct 用于实现模型映射器,关于其使用见 https://www.jianshu.com/p/53aac78e7d60
- 数据存储采用 mysql,数据库操作框架使用 mybatis,可以看到,领域层对具体的技术实现并不关注,仅关注业务,通过 DDD 实现了技术细节与业务逻辑的解耦。
资源库实现 io.study.order.repository.impl.OrderRepositoryImpl
/**
* 订单资源库实现类
*/
@Repository
public class OrderRepositoryImpl implements OrderRepository {
@Resource
private OrderDAO orderDAO;
@Override
public void add(Order order) {
orderDAO.insertSelective(OrderDOConverter.INSTANCE.toDO(order));
}
@Override
public Order orderOfId(OrderId orderId) {
OrderDO orderDO = orderDAO.selectByPrimaryKey(orderId.getId());
return OrderDOConverter.INSTANCE.fromDO(orderDO);
}
}
数据库操作接口 io.study.order.data.OrderDAO
/**
* 订单 DAO
* 使用 mybatis-generator 自动生成
*/
@org.apache.ibatis.annotations.Mapper
public interface OrderDAO {
int insertSelective(OrderDO record);
OrderDO selectByPrimaryKey(Long id);
}
数据库实现类 resources/mapper/OrderDAO.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="io.study.order.data.OrderDAO">
<resultMap id="BaseResultMap" type="io.study.order.data.OrderDO">
<id column="id" property="id" jdbcType="BIGINT"/>
<result column="name" property="name" jdbcType="VARCHAR"/>
</resultMap>
<sql id="Base_Column_List">
id, name
</sql>
<select id="selectByPrimaryKey" resultMap="BaseResultMap" parameterType="java.lang.Long">
select
<include refid="Base_Column_List"/>
from `order`
where id = #{id,jdbcType=BIGINT}
</select>
<insert id="insertSelective" parameterType="io.study.order.data.OrderDO">
insert into `order`
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="id != null">
id,
</if>
<if test="name != null">
name,
</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="id != null">
#{id,jdbcType=BIGINT},
</if>
<if test="name != null">
#{name,jdbcType=VARCHAR},
</if>
</trim>
</insert>
</mapper>
数据对象
/**
* 订单数据库对象
*/
@Data
public class OrderDO {
/**
* 订单 ID
*/
private Long id;
/**
* 订单名称
*/
private String name;
}
数据对象转换器 io.study.order.data.OrderDOConverter
/**
* OrderDO 转换器
*/
@org.mapstruct.Mapper
public interface OrderDOConverter {
OrderDOConverter INSTANCE = Mappers.getMapper(OrderDOConverter.class);
@Mapping(source = "id.id", target = "id")
OrderDO toDO(Order order);
@Mapping(target = "id", expression = "java(OrderId.of(orderDO.getId()))")
void update(OrderDO orderDO, @MappingTarget Order order);
default Order fromDO(OrderDO orderDO) {
Order order = OrderFactory.createOrder();
INSTANCE.update(orderDO, order);
return order;
}
}
在创建实体对象时,需要使用工厂进行创建,这样才能为实体注入资源库实现。
用户接口层代码模型
包依赖
<dependencies>
<!-- 领域模块 -->
<dependency>
<groupId>io.study</groupId>
<artifactId>order-domain</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 应用模块 -->
<dependency>
<groupId>io.study</groupId>
<artifactId>order-application</artifactId>
<version>${project.version}</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- mapstruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<scope>provided</scope>
</dependency>
<!-- springboot-web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- springfox -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
</dependency>