简介: 在日常工作中我观察到,面对老系统重构和迁移场景,有大量代码属于流水账代码,通常能看到开发在对外的API接口里直接写业务逻辑代码,或者在一个服务里大量的堆接口,导致业务逻辑实际无法收敛,接口复用性比较差。所以本文主要想系统性的解释一下如何通过DDD的重构,将原有的流水账代码改造为逻辑清晰、职责分明的模块。
作者 | 殷浩 来源 | 阿里技术公众号
在日常工作中我观察到,面对老系统重构和迁移场景,有大量代码属于流水账代码,通常能看到开发在对外的API接口里直接写业务逻辑代码,或者在一个服务里大量的堆接口,导致业务逻辑实际无法收敛,接口复用性比较差。所以本文主要想系统性的解释一下如何通过DDD的重构,将原有的流水账代码改造为逻辑清晰、职责分明的模块。
一 案例简介这里举一个简单的常见案例:下单链路。假设我们在做一个checkout接口,需要做各种校验、查询商品信息、调用库存服务扣库存、然后生成订单:
一个比较典型的代码如下:
@RestController
@RequestMapping("/")
public class CheckoutController {
@Resource
private ItemService itemService;
@Resource
private InventoryService inventoryService;
@Resource
private OrderRepository orderRepository;
@PostMapping("checkout")
public Result checkout(Long itemId, Integer quantity) {
// 1) Session管理
Long userId = SessionUtils.getLoggedInUserId();
if (userId queryBySellerId(Long sellerId);
List < OrderDO> queryBySellerIdWithPage(Long sellerId, int currentPage, int pageSize);
可以看出来,传统的接口写法有几个问题:
- 接口膨胀:一个查询条件一个方法。
- 难以扩展:每新增一个参数都有可能需要调用方升级。
- 难以测试:接口一多,职责随之变得繁杂,业务场景各异,测试用例难以维护。
但是另外一个最重要的问题是:这种类型的参数罗列,本身没有任何业务上的”语意“,只是一堆参数而已,无法明确的表达出来意图。
CQE的规范
所以在Application层的接口里,强力建议的一个规范是:
ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。唯一可以的例外是根据单一ID查询的情况,可以省略掉一个Query对象的创建。按照上面的规范,实现案例是:
public interface CheckoutService {
OrderDTO checkout(@Valid CheckoutCommand cmd);
List query(OrderQuery query);
OrderDTO getOrder(Long orderId); // 注意单一ID查询可以不用Query
}
@Data
public class CheckoutCommand {
private Long userId;
private Long itemId;
private Integer quantity;
}
@Data
public class OrderQuery {
private Long sellerId;
private Long itemId;
private int currentPage;
private int pageSize;
}
这个规范的好处是:提升了接口的稳定性、降低低级的重复,并且让接口入参更加语意化。
CQE vs DTO
从上面的代码能看出来,ApplicationService的入参是CQE对象,但是出参却是一个DTO,从代码格式上来看都是简单的POJO对象,那么他们之间有什么区别呢?
- CQE:CQE对象是ApplicationService的输入,是有明确的”意图“的,所以这个对象必须保证其”正确性“。
- DTO:DTO对象只是数据容器,只是为了和外部交互,所以本身不包含任何逻辑,只是贫血对象。
但可能最重要的一点:因为CQE是”意图“,所以CQE对象在理论上可以有”无限“个,每个代表不同的意图;但是DTO作为模型数据容器,和模型一一对应,所以是有限的。
CQE的校验
CQE作为ApplicationService的输入,必须保证其正确性,那么这个校验是放在哪里呢?
在最早的代码里,曾经有这样的校验逻辑,当时写在了服务里:
if (itemId 1,但是仅仅代表了中断条件,具体的业务逻辑处理并没有受影响。可以把它看作为Precondition。
(2)不要有任何计算
在最早的代码里有这个计算:
// 5)领域计算
Long cost = item.getPriceInCents() * quantity;
order.setTotalCost(cost);
通过将这个计算逻辑封装到实体里,避免在ApplicationService里做计算:
@Data
public class Order {
private Long itemUnitPrice;
private Integer count;
// 把原来一个在ApplicationService的计算迁移到Entity里
public Long getTotalCost() {
return itemUnitPrice * count;
}
}
order.setItemUnitPrice(item.getPriceInCents());
order.setCount(cmd.getQuantity());
(3)一些数据的转化可以交给其他对象来做
比如DTO Assembler,将对象间转化的逻辑沉淀在单独的类中,降低ApplicationService的复杂度。
OrderDTO dto = orderDtoAssembler.orderToDTO(savedOrder);
常用的ApplicationService“套路”
我们可以看出来,ApplicationService的代码通常有类似的结构:AppService通常不做任何决策(Precondition除外),仅仅是把所有决策交给DomainService或Entity,把跟外部交互的交给Infrastructure接口,如Repository或防腐层。
一般的“套路”如下:
- 准备数据:包括从外部服务或持久化源取出相对应的Entity、VO以及外部服务返回的DTO。
- 执行操作:包括新对象的创建、赋值,以及调用领域对象的方法对其进行操作。需要注意的是这个时候通常都是纯内存操作,非持久化。
- 持久化:将操作结果持久化,或操作外部系统产生相应的影响,包括发消息等异步操作。
如果涉及到对多个外部系统(包括自身的DB)都有变更的情况,这个时候通常处在“分布式事务”的场景里,无论是用分布式TX、TCC、还是Saga模式,取决于具体场景的设计,在此处暂时略过。
4 DTO Assembler
一个经常被忽视的问题是 ApplicationService应该返回 Entity 还是 DTO?这里提出一个规范,在DDD分层架构中:
ApplicationService应该永远返回DTO而不是Entity。
为什么呢?
- 构建领域边界:ApplicationService的入参是CQE对象,出参是DTO,这些基本上都属于简单的POJO,来确保Application层的内外互相不影响。
- 降低规则依赖:Entity里面通常会包含业务规则,如果ApplicationService返回Entity,则会导致调用方直接依赖业务规则。如果内部规则变更可能直接影响到外部。
- 通过DTO组合降低成本:Entity是有限的,DTO可以是多个Entity、VO的自由组合,一次性封装成复杂DTO,或者有选择的抽取部分参数封装成DTO可以降低对外的成本。
因为我们操作的对象是Entity,但是输出的对象是DTO,这里就需要一个专属类型的对象叫DTO Assembler。DTO Assembler的唯一职责是将一个或多个Entity/VO,转化为DTO。注意:DTO Assembler通常不建议有反操作,也就是不会从DTO到Entity,因为通常一个DTO转化为Entity时是无法保证Entity的准确性的。
通常,Entity转DTO是有成本的,无论是代码量还是运行时的操作。手写转换代码容易出错,为了节省代码量用Reflection会造成极大的性能损耗。所以这里我还是不遗余力的推荐MapStruct这个库。MapStruct通过静态编译时代码生成,通过写接口和配置注解就可以生成对应的代码,且因为生成的代码是直接赋值,其性能损耗基本可以忽略不计。
通过MapStruct,代码即可简化为:
import org.mapstruct.Mapper;
@Mapper
public interface OrderDtoAssembler {
OrderDtoAssembler INSTANCE = Mappers.getMapper(OrderDtoAssembler.class);
OrderDTO orderToDTO(Order order);
}
public class CheckoutServiceImpl implements CheckoutService {
private final OrderDtoAssembler orderDtoAssembler = OrderDtoAssembler.INSTANCE;
@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
// ...
Order order = new Order();
// ...
Order savedOrder = orderRepository.save(order);
return orderDtoAssembler.orderToDTO(savedOrder);
}
}
结合之前的Data Mapper,DTO、Entity和DataObject之间的关系如下图:

5 Result vs Exception
最后,上文曾经提及在Interface层应该返回Result,在Application层应该返回DTO,在这里再次重复提出规范:
Application层只返回DTO,可以直接抛异常,不用统一处理。所有调用到的服务也都可以直接抛异常,除非需要特殊处理,否则不需要刻意捕捉异常。
异常的好处是能明确的知道错误的来源,堆栈等,在Interface层统一捕捉异常是为了避免异常堆栈信息泄漏到API之外,但是在Application层,异常机制仍然是信息量最大,代码结构最清晰的方法,避免了Result的一些常见且繁杂的Result.isSuccess判断。所以在Application层、Domain层,以及Infrastructure层,遇到错误直接抛异常是最合理的方法。
6 Anti-Corruption Layer防腐层
本文仅仅简单描述一下ACL的原理和作用,具体的实施规范可能要等到另外一篇文章。
在ApplicationService中,经常会依赖外部服务,从代码层面对外部系统产生了依赖。比如上文中的:
ItemDO item = itemService.getItem(cmd.getItemId());
boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
会发现我们的ApplicationService会强依赖ItemService、InventoryService以及ItemDO这个对象。如果任何一个服务的方法变更,或者ItemDO字段变更,都会有可能影响到ApplicationService的代码。也就是说,我们自己的代码会因为强依赖了外部系统的变化而变更,这个在复杂系统中应该是尽量避免的。那么如何做到对外部系统的隔离呢?需要加入ACL防腐层。
ACL防腐层的简单原理如下:
- 对于依赖的外部对象,我们抽取出所需要的字段,生成一个内部所需的VO或DTO类。
- 构建一个新的Facade,在Facade中封装调用链路,将外部类转化为内部类。
- 针对外部系统调用,同样的用Facade方法封装外部调用链路。
无防腐层的情况:

有防腐层的情况:

具体简单实现,假设所有外部依赖都命名为ExternalXXXService:
@Data
public class ItemDTO {
private Long itemId;
private Long sellerId;
private String title;
private Long priceInCents;
}
// 商品Facade接口
public interface ItemFacade {
ItemDTO getItem(Long itemId);
}
// 商品facade实现
@Service
public class ItemFacadeImpl implements ItemFacade {
@Resource
private ExternalItemService externalItemService;
@Override
public ItemDTO getItem(Long itemId) {
ItemDO itemDO = externalItemService.getItem(itemId);
if (itemDO != null) {
ItemDTO dto = new ItemDTO();
dto.setItemId(itemDO.getItemId());
dto.setTitle(itemDO.getTitle());
dto.setPriceInCents(itemDO.getPriceInCents());
dto.setSellerId(itemDO.getSellerId());
return dto;
}
return null;
}
}
// 库存Facade
public interface InventoryFacade {
boolean withhold(Long itemId, Integer quantity);
}
@Service
public class InventoryFacadeImpl implements InventoryFacade {
@Resource
private ExternalInventoryService externalInventoryService;
@Override
public boolean withhold(Long itemId, Integer quantity) {
return externalInventoryService.withhold(itemId, quantity);
}
}
通过ACL改造之后,我们ApplicationService的代码改为:
@Service
public class CheckoutServiceImpl implements CheckoutService {
@Resource
private ItemFacade itemFacade;
@Resource
private InventoryFacade inventoryFacade;
@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
ItemDTO item = itemFacade.getItem(cmd.getItemId());
if (item == null) {
throw new IllegalArgumentException("Item not found");
}
boolean withholdSuccess = inventoryFacade.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
throw new IllegalArgumentException("Inventory not enough");
}
// ...
}
}
很显然,这么做的好处是ApplicationService的代码已经完全不再直接依赖外部的类和方法,而是依赖了我们自己内部定义的值类和接口。如果未来外部服务有任何的变更,需要修改的是Facade类和数据转化逻辑,而不需要修改ApplicationService的逻辑。
Repository可以认为是一种特殊的ACL,屏蔽了具体数据操作的细节,即使底层数据库结构变更,数据库类型变更,或者加入其他的持久化方式,Repository的接口保持稳定,ApplicationService就能保持不变。
在一些理论框架里ACL Facade也被叫做Gateway,含义是一样的。
四 Orchestration vs Choreography
在本文最后想聊一下复杂业务流程的设计规范。在复杂的业务流程里,我们通常面临两种模式:Orchestration 和 Choreography。很无奈,这两个英文单词的百度翻译/谷歌翻译,都是“编排”,但实际上这两种模式是完全不一样的设计模式。
Orchestration的编排(比如SOA/微服务的服务编排Service Orchestration)是我们通常熟悉的用法,Choreography是最近出现了事件驱动架构EDA才慢慢流行起来。网上可能会有其他的翻译,比如编制、编舞、协作等,但感觉都没有真正的把英文单词的意思表达出来,所以为了避免误解,在下文我尽量还是用英文原词。如果谁有更好的翻译方法欢迎联系我。
1 模式简介
- Orchestration:通常出现在脑海里的是一个交响乐团(Orchestra,注意这两个词的相似性)。交响乐团的核心是一个唯一的指挥家Conductor,在一个交响乐中,所有的音乐家必须听从Conductor的指挥做操作,不可以独自发挥。所以在Orchestration模式中,所有的流程都是由一个节点或服务触发的。我们常见的业务流程代码,包括调用外部服务,就是Orchestration,由我们的服务统一触发。
- Choreography:通常会出现在脑海的场景是一个舞剧(来自于希腊文的舞蹈,Choros)。其中每个不同的舞蹈家都在做自己的事,但是没有一个中心化的指挥。通过协作配合,每个人做好自己的事,整个舞蹈可以展现出一个完整的、和谐的画面。所以在Choreography模式中,每个服务都是独立的个体,可能会响应外部的一些事件,但整个系统是一个整体。
2 案例
用一个常见的例子:下单后支付并发货。
如果这个案例是Orchestration,则业务逻辑为:下单时从一个预存的账户里扣取资金,并且生成物流单发货,从图上看是这样的:

如果这个案例是Choreography,则业务逻辑为:下单,然后等支付成功事件,然后再发货,类似这样:

3 模式的区别和选择
虽然看起来这两种模式都能达到一样的业务目的,但是在实际开发中他们有巨大的差异。
从代码依赖关系来看:
- Orchestration:涉及到一个服务调用到另外的服务,对于调用方来说,是强依赖的服务提供方。
- Choreography:每一个服务只是做好自己的事,然后通过事件触发其他的服务,服务之间没有直接调用上的依赖。但要注意的是下游还是会依赖上游的代码(比如事件类),所以可以认为是下游对上游有依赖。
从代码灵活性来看:
- Orchestration:因为服务间的依赖关系是写死的,增加新的业务流程必然需要修改代码。
- Choreography:因为服务间没有直接调用关系,可以增加或替换服务,而不需要改上游代码。
从调用链路来看:
- Orchestration:是从一个服务主动调用另一个服务,所以是Command-Driven指令驱动的。
- Choreography:是每个服务被动的被外部事件触发,所以是Event-Driven事件驱动的。
从业务职责来看:
- Orchestration:有主动的调用方(比如:下单服务)。无论下游的依赖是谁,主动的调用方都需要为整个业务流程和结果负责。
- Choreography:没有主动调用方,每个服务只关心自己的触发条件和结果,没有任何一个服务会为整个业务链路负责。
小结:

另外需要重点明确的:“指令驱动”和“事件驱动”的区别不是“同步”和“异步”。指令可以是同步调用,也可以是异步消息触发(但异步指令不是事件);反过来事件可以是异步消息,但也完全可以是进程内的同步调用。所以指令驱动和事件驱动差异的本质不在于调用方式,而是一件事情是否“已经”发生。
所以在日常业务中当你碰到一个需求时,该如何选择是用Orchestration还是Choreography?
这里给出两个判断方法:
(1)明确依赖的方向

在代码中的依赖是比较明确的:如果你是下游,上游对你无感知,则只能走事件驱动;如果上游必须要对你有感知,则可以走指令驱动。反过来,如果你是上游,需要对下游强依赖,则是指令驱动;如果下游是谁无所谓,则可以走事件驱动。
(2)找出业务中的“负责人”

第二种方法是根据业务场景找出其中的“负责人”。比如,如果业务需要通知卖家,下单系统的单一职责不应该为消息通知负责,但订单管理系统需要根据订单状态的推进主动触发消息,所以是这个功能的负责人。
在一个复杂业务流程里,通常两个模式都要有,但也很容易设计错误。如果出现依赖关系很奇怪,或者代码里调用链路/负责人梳理不清楚的情况,可以尝试转换一下模式,可能会好很多。
哪个模式更好?
很显然,没有最好的模式,只有最合适自己业务场景的模式。
反例:最近几年比较流行的Event-Driven Architecture(EDA)事件驱动架构,以及Reactive-Programming响应式编程(比如RxJava),虽然有很多创新,但在一定程度上是“当你有把锤子,所有问题都是钉子”的典型案例。他们对一些基于事件的、流处理的问题有奇效,但如果拿这些框架硬套指令驱动的业务,就会感到代码极其“不协调”,认知成本提高。所以在日常选型中,还是要先根据业务场景梳理出来是哪些流程中的部分是Orchestration,哪些是Choreography,然后再选择相对应的框架。
4 跟DDD分层架构的关系
最后,讲了这么多O vs C,跟DDD有啥关系?很简单:
- O&C其实是Interface层的关注点,Orchestration = 对外的API,而Choreography = 消息或事件。当你决策了O还是C之后,需要在Interface层承接这些“驱动力”。
- 无论O&C如何设计,Application层都“无感知”,因为ApplicationService天生就可以处理Command、Query和Event,至于这些对象怎么来,是Interface层的决策。
所以,虽然Orchestration 和 Choreography是两种完全不同的业务设计模式,但最终落到Application层的代码应该是一致的,这也是为什么Application层是“用例”而不是“接口”,是相对稳定的存在。
五 总结
只要是做业务的,一定会需要写业务流程和服务编排,但不代表这种代码一定质量差。通过DDD的分层架构里的Interface层和Application层的合理拆分,代码可以变得优雅、灵活,能更快的响应业务但同时又能更好的沉淀。本文主要介绍了一些代码的设计规范,帮助大家掌握一定的技巧。
Interface层:
- 职责:主要负责承接网络协议的转化、Session管理等。
- 接口数量:避免所谓的统一API,不必人为限制接口类的数量,每个/每类业务对应一套接口即可,接口参数应该符合业务需求,避免大而全的入参。
- 接口出参:统一返回Result。
- 异常处理:应该捕捉所有异常,避免异常信息的泄漏。可以通过AOP统一处理,避免代码里有大量重复代码。
Application层:
- 入参:具像化Command、Query、Event对象作为ApplicationService的入参,唯一可以的例外是单ID查询的场景。
- CQE的语意化:CQE对象有语意,不同用例之间语意不同,即使参数一样也要避免复用。
- 入参校验:基础校验通过Bean Validation api解决。Spring Validation自带Validation的AOP,也可以自己写AOP。
- 出参:统一返回DTO,而不是Entity或DO。
- DTO转化:用DTO Assembler负责Entity/VO到DTO的转化。
- 异常处理:不统一捕捉异常,可以随意抛异常。
部分Infra层:
- 用ACL防腐层将外部依赖转化为内部代码,隔离外部的影响。
业务流程设计模式:
- 没有最好的模式,取决于业务场景、依赖关系、以及是否有业务“负责人”。避免拿着锤子找钉子。
原文链接 本文为阿里云原创内容,未经允许不得转载。