上一篇文章中我们把应用服务、聚合根和领域服务的职责分清楚了。应用服务负责完成一次用例聚合根负责守住业务规则领域服务处理无法自然归属单个对象的领域规则。代码写到这里很快会遇到下一个问题应用服务需要先拿到聚合再调用业务方法最后保存聚合。这个“拿到”和“保存”到底应该怎么做领域对象要不要直接调用 Mapper这就是仓储要解决的问题。Repository 是领域层访问聚合的抽象它让上层按领域对象思考而不是按表、SQL 和 ORM 框架思考。这一篇只讲仓储本身。至于项目具体怎么分包放到第 12 章再统一整理。一、为什么需要仓储先看一个没有仓储的订单支付方法Transactionalpublicvoidpay(StringorderNo){OrderDOorderDOorderMapper.selectByOrderNo(orderNo);OrderorderorderConverter.toDomain(orderDO);order.pay();orderMapper.updateById(orderConverter.toData(order));}这段代码不算错但应用服务已经知道了 Mapper、DO 和数据库更新方式。以后从 MyBatis 换成 JPA或者订单需要同时加载明细表应用服务都要跟着调整。更麻烦的是业务代码很容易慢慢退回“查一条表记录、改几个字段、执行 update”的数据表思维。仓储把这部分细节收起来Transactionalpublicvoidpay(StringorderNo){OrderorderorderRepository.findByOrderNo(orderNo).orElseThrow(()-newIllegalArgumentException(订单不存在));order.pay();orderRepository.save(order);}应用服务只关心三件事加载订单、执行支付、保存订单。至于订单来自几张表、使用什么 ORM、如何组装对象交给仓储实现处理。二、仓储不是 DAO 换了个名字DAO 或 Mapper 通常围绕数据表工作仓储则围绕聚合工作。对比项DAO / MapperRepository面向对象表记录、DO聚合根常见方法selectById、updateByIdfindByOrderNo、save返回结果DO、Map、数据行完整聚合关注点SQL 和持久化操作领域对象的获取与保存所在语义基础设施语义领域语义仓储底层完全可以继续使用 Mapper但不应该把 Mapper 直接暴露给领域层和应用层。如果一个所谓的仓储仍然提供updateStatus、selectPage、deleteByCondition这类表操作方法它多半只是换了名字的 DAO。三、仓储接口和实现怎么写1. 接口围绕聚合根定义仓储接口表达的是领域需要什么能力所以通常跟聚合放在同一个领域模块中。/** * 订单仓储 * * 该接口只描述订单聚合的获取和保存能力不暴露 SQL、分页插件等技术细节。 */publicinterfaceOrderRepository{/** * 根据订单编号加载完整订单聚合 * * 返回的订单必须包含执行业务行为所需的数据不能只返回一条不完整的订单主表记录。 */OptionalOrderfindByOrderNo(StringorderNo);/** * 保存订单聚合 * * 由仓储实现负责识别新增或更新并持久化聚合边界内需要同步保存的数据。 */voidsave(Orderorder);}接口名称要使用业务语言。findByOrderNo比selectOne更能说明调用意图。2. 实现负责持久化细节仓储实现放在基础设施层可以依赖 MyBatis、JPA、数据库表对象和转换器。RepositorypublicclassMyBatisOrderRepositoryimplementsOrderRepository{privatefinalOrderMapperorderMapper;privatefinalOrderItemMapperorderItemMapper;privatefinalOrderDataConverterorderDataConverter;/** * 加载订单主表和明细表并恢复成能够执行业务行为的完整聚合。 */OverridepublicOptionalOrderfindByOrderNo(StringorderNo){OrderDOorderDOorderMapper.selectByOrderNo(orderNo);if(orderDOnull){returnOptional.empty();}ListOrderItemDOitemDOListorderItemMapper.selectByOrderId(orderDO.getId());returnOptional.of(orderDataConverter.toDomain(orderDO,itemDOList));}/** * 将聚合拆成数据库模型后保存。 * * 这里必须在同一事务内维护订单主表和明细表的一致性调用方不需要知道具体表结构。 */Overridepublicvoidsave(Orderorder){OrderDOorderDOorderDataConverter.toOrderDO(order);orderMapper.saveOrUpdate(orderDO);orderItemMapper.replaceByOrderId(orderDO.getId(),orderDataConverter.toItemDOList(orderDO.getId(),order.getItems()));}}下面这张图把完整链路串起来。仓储接口属于领域语义仓储实现和 Mapper 才负责技术细节。四、领域模型和数据模型怎么转换领域对象和数据库对象通常不是一一对应的。一个Order聚合可能对应订单主表、订单明细表和多个值对象反过来一张宽表也可能被转换成多个领域概念。所以不要依赖BeanUtils.copyProperties()假装两者完全相同。/** * 订单持久化模型转换器 * * 负责数据库模型与领域模型之间的转换所有字段都要按业务含义显式映射。 */publicclassOrderDataConverter{/** 将数据库记录恢复成订单聚合 */publicOrdertoDomain(OrderDOorderDO,ListOrderItemDOitemDOList){MoneytotalAmountnewMoney(orderDO.getTotalAmount(),orderDO.getCurrency());ListOrderItemitemsitemDOList.stream().map(this::toDomainItem).toList();returnOrder.restore(newOrderId(orderDO.getOrderNo()),orderDO.getBuyerId(),items,totalAmount,OrderStatus.valueOf(orderDO.getStatus()),orderDO.getVersion());}}这里使用restore是为了区分两种动作create表示新建订单需要执行新建规则并产生相应行为restore表示从存储中恢复已经存在的订单不能再次触发“订单创建”规则。从数据库恢复对象不等于创建一个新业务对象。这两个入口混在一起很容易重复产生领域事件或覆盖历史状态。五、保存聚合时要注意什么1. 一个仓储对应一个聚合根外部只通过聚合根仓储加载和保存聚合。订单明细属于订单聚合内部时不需要再提供一个能被应用服务随意调用的OrderItemRepository。否则外部就可能绕过Order直接修改订单明细聚合边界也就失效了。2. 事务通常放在应用服务应用服务知道一次用例从哪里开始、到哪里结束因此通常由它控制事务。仓储负责保存不负责决定整条用例的事务范围。TransactionalpublicvoidchangeAddress(ChangeOrderAddressCommandcommand){OrderorderorderRepository.findByOrderNo(command.orderNo()).orElseThrow(()-newIllegalArgumentException(订单不存在));order.changeAddress(command.address());orderRepository.save(order);}3. 并发更新要有保护两个请求同时加载同一订单时都可能基于旧状态修改。常见做法是给聚合增加版本号并在更新 SQL 中带上旧版本条件UPDATEordersSETstatus#{status},versionversion1WHEREorder_no#{orderNo}ANDversion#{version};如果影响行数为 0说明数据已经被其他请求修改应该抛出并发冲突异常而不是静默覆盖。仓储保存的不只是数据还要维护聚合在并发场景下的一致性。六、查询一定要走仓储吗不一定。仓储适合加载需要执行业务行为的聚合。列表、报表、联表详情等只读查询如果也强行恢复完整聚合代码会很重性能也未必合适。这类场景可以单独定义查询服务直接返回页面需要的 DTO 或 VOpublicinterfaceOrderQueryService{/** 按页面条件查询订单列表只返回展示所需字段 */PageResultOrderListVOpage(OrderPageQueryquery);}这已经带有 CQRS 的思路修改操作通过领域模型查询操作使用更适合读取的模型。第 16 章会把 CQRS 和其他解耦方案放到一起比较这里先不展开。七、怎么验证仓储是否合理可以从三层测试入手用普通单元测试验证转换器确认 DO 能恢复成完整聚合值对象和状态没有丢失用数据库集成测试验证仓储保存后重新加载聚合关键字段应保持一致用应用服务测试验证调用顺序业务失败时不保存成功时只保存一次。还可以直接做一次代码检查应用层是否还出现 Mapper、DO 和 SQL 条件领域层是否依赖 MyBatis、JPA 注解或数据库框架仓储是否按聚合加载而不是只返回残缺主表数据查询接口是否为了“统一”而强行恢复聚合。看到应用服务只使用OrderRepository和Order基础设施实现可以独立替换说明边界基本是清楚的。八、总结这一篇把 Repository 的落地方式整理了一遍。仓储接口面向聚合仓储实现面向数据库应用服务只看领域对象Mapper 和 DO 留在基础设施层。同时还要记住仓储不是 DAO 改名一个聚合根对应一个仓储保存时要考虑事务和并发只读查询也不必强行走聚合。下一篇继续学习领域事件。聚合完成业务行为之后其他聚合或外部模块怎么知道发生了什么就要靠事件来协作了。