知识图谱
四层架构总览
DDD 的代码组织采用四层架构,每一层有明确的职责边界,层与层之间通过接口解耦。
核心设计理念:依赖方向始终指向领域层。 领域层是整个系统的核心,它不依赖任何其他层,其他层都依赖领域层。
各层职责详解
分层的核心依据是"业务意图和职责",而不是技术实现。
接口层(Interface Layer)
接口层是系统的对外门面,负责接收所有外部触发信号并将其转发给应用层。它本身不包含任何业务逻辑,只做协议适配:将外部信号转换成 Command/Query 对象交给应用层,再将结果翻译回响应格式。
MVC 对照: 对应 MVC 的 Controller,职责几乎不变------接收请求、返回响应。区别在于 DDD 的接口层更薄:不做任何业务判断。
四类入口:
| 入口类型 | 触发源 | 常见子包 | 典型写法 |
|---|---|---|---|
| HTTP Controller | 用户 HTTP 请求 | interfaces/rest/ |
@RestController |
| MQ 消费者 | 消息队列推消 | interfaces/consumer/ |
@RabbitListener / @KafkaListener |
| RPC 服务端 | 内部服务调用 | interfaces/rpc/ |
@DubboService / @GrpcService |
| 定时任务 | 时间调度信号 | interfaces/job/ |
@Scheduled / @XxlJob |
HTTP、MQ 消息、时间信号本质上都是"外部驱动应用执行用例",在接口层的地位完全对称。
不允许出现: 业务判断、直接调用仓储、直接操作领域对象。
java
// ✅ 接口层标准写法:接收外部信号 → 组装 Command → 调用应用层 → 返回响应
@RestController
@RequestMapping("/orders")
public class OrderController {
private final PlaceOrderCommandHandler placeOrderHandler;
@PostMapping
public ResponseEntity<PlaceOrderResponse> placeOrder(
@RequestBody @Valid PlaceOrderRequest request,
@AuthenticationPrincipal UserPrincipal currentUser) {
PlaceOrderCommand cmd = new PlaceOrderCommand(
CustomerId.of(currentUser.getId()),
ProductId.of(request.getProductId()),
request.getQuantity(),
Address.of(request.getProvince(), request.getCity(), request.getStreet())
);
return ResponseEntity.ok(PlaceOrderResponse.from(placeOrderHandler.handle(cmd)));
}
}
📖 接口层的完整落地实现(MQ 消费者、定时任务入口、配置类归属、横切关注点处理)详见 06-接口层详解。
应用层(Application Layer)
应用层是用例的编排者,它负责协调领域对象、领域服务和基础设施能力来完成一个完整的业务流程,但本身不包含任何业务规则。
MVC 对照: 对应 MVC Service 中的流程编排部分。MVC 的 Service 把"加载数据、业务判断、保存数据"混在一起;DDD 将纯粹的流程组织(加载聚合 → 调用领域方法 → 保存聚合)单独提取到应用层,业务判断下沉到领域层,让应用层保持"无业务逻辑"。
职责清单:
- 应用服务(Application Service):用例编排的核心,负责加载聚合、调用领域对象/服务、保存聚合、发布领域事件、控制事务边界。
- 事件监听器(Event Listener):可放在应用层或接口层,依据其监听后的业务职责归属。若监听后仅做编排/转发,建议放应用层;若仅做协议适配或外部事件转发,可放接口层。
判断标准: 如果应用服务里出现了 if/else 业务判断,通常意味着业务逻辑应该移入领域层。
同样的技术实现(如 @TransactionalEventListener),根据监听后的业务职责不同,分层也不同。事件监听器不是必须属于应用层,需结合实际业务意图判断。
java
// ✅ 应用服务标准结构(黄金公式:加载 → 执行 → 保存 → 发布事件)
@ApplicationService
@Transactional
public class PlaceOrderCommandHandler {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final PricingDomainService pricingDomainService;
private final CouponQueryPort couponQueryPort;
public PlaceOrderResult handle(PlaceOrderCommand cmd) {
// Step 1: 加载聚合
Product product = productRepository.findById(cmd.getProductId())
.orElseThrow(() -> new ProductNotFoundException(cmd.getProductId()));
List<Coupon> coupons = couponQueryPort.getApplicableCoupons(cmd.getCustomerId());
// Step 2: 创建聚合并执行业务逻辑(规则在领域对象内部)
Order order = Order.create(cmd.getCustomerId(), cmd.getShippingAddress());
order.addItem(product.getId(), product.getName(), product.getPrice(), cmd.getQuantity());
// Step 3: 调用领域服务处理复杂计算
Money finalPrice = pricingDomainService.calculateFinalPrice(order, coupons);
order.confirmPrice(finalPrice);
// Step 4: 保存聚合
orderRepository.save(order);
// Step 5: 领域事件由仓储实现在事务提交后自动发布
return new PlaceOrderResult(order.getId(), finalPrice);
}
}
领域层(Domain Layer)
领域层是整个系统最核心、最稳定的一层,包含所有业务规则和领域知识。它不依赖任何框架或外部系统。
MVC 对照: 对应 MVC Service 中的业务规则部分 ,也是 DDD 最核心的改造。MVC 里写在 Service 方法体里的
if (status != PENDING) throw、total = price * qty等判断和计算,在 DDD 中全部移入领域对象自身(Order.pay()、OrderItem.calculateSubtotal()),让对象携带行为而不只是数据。该层完全不依赖 Spring 和数据库,可直接new Order()编写纯单元测试。
包含的内容:
- 聚合根、实体、值对象(业务规则的载体)
- 领域服务(跨实体/跨聚合的业务操作)
- 仓储接口(只定义接口,不关心实现)
- 领域事件(业务事实的记录)
- 领域异常
设计原则: 领域层的代码应该可以在没有 Spring、没有数据库的情况下独立运行和测试。"不依赖框架"的真正含义不是"只能用 JDK",而是禁止引入会将领域模型绑定到某种技术实现的依赖。以下表格给出精确的许可边界:
领域层依赖许可边界
| 类别 | 典型示例 | 是否允许 | 原因 |
|---|---|---|---|
| JDK 标准库 | java.util、java.time |
✅ | 无任何框架绑定 |
| Lombok | @Getter、@Builder、@Value |
✅ | 纯编译期注解,运行时零依赖 |
| SLF4J API | LoggerFactory.getLogger |
✅ | 只是日志门面接口,不引入具体实现 |
| JPA / Hibernate 注解 | @Entity、@Column、@Table |
❌ | 将领域对象绑定到数据库表结构 |
| Spring 生命周期注解 | @Component、@Service、@Autowired |
❌ | 领域对象不应由 IoC 容器管理 |
| Spring 事务 | @Transactional |
❌ | 事务边界控制属于应用层职责 |
ApplicationEvent 基类 |
extends ApplicationEvent |
❌ | 领域事件不应与 Spring 事件机制耦合 |
| 数据库驱动 / ORM | JDBC、EntityManager |
❌ | 持久化细节属于基础设施层 |
| MQ / HTTP 客户端 | RabbitTemplate、Feign |
❌ | 外部通信属于基础设施层 |
关于
ApplicationEvent的正确做法: 领域层的DomainEvent是纯 POJO,仅依赖 JDK。同时在领域层定义DomainEventPublisher接口(依赖倒置),由基础设施层实现该接口并在内部使用 Spring 的ApplicationEventPublisher发布事件------领域层完全感知不到 Spring 事件机制。Spring 4.2+ 支持直接发布任意对象(不再强制继承ApplicationEvent),因此基础设施层直接调用springPublisher.publishEvent(domainEvent)即可,无需任何包装。
java
// ✅ 领域层:纯 POJO 事件基类(零框架依赖)
public abstract class DomainEvent {
private final String eventId = UUID.randomUUID().toString();
private final Instant occurredOn = Instant.now();
public String getEventId() { return eventId; }
public Instant getOccurredOn() { return occurredOn; }
}
// ✅ 领域层:事件发布接口(定义在此,实现在基础设施层)
public interface DomainEventPublisher {
void publish(DomainEvent event);
}
// ✅ 基础设施层:Spring 适配,领域层对此毫无感知
@Component
public class SpringDomainEventPublisher implements DomainEventPublisher {
private final ApplicationEventPublisher springPublisher;
public SpringDomainEventPublisher(ApplicationEventPublisher springPublisher) {
this.springPublisher = springPublisher;
}
@Override
public void publish(DomainEvent event) {
// Spring 4.2+ 支持直接发布非 ApplicationEvent 对象
springPublisher.publishEvent(event);
}
}
java
// ✅ 领域层:仓储接口定义
// 注意:这个接口定义在领域层,操作的是领域对象 Order,不是数据库表
public interface OrderRepository {
Optional<Order> findById(OrderId orderId);
List<Order> findByCustomerId(CustomerId customerId);
void save(Order order);
void delete(OrderId orderId);
}
// ✅ 领域层:领域服务接口
public interface PricingDomainService {
Money calculateFinalPrice(Order order, List<Coupon> coupons);
}
基础设施层(Infrastructure Layer)
基础设施层负责实现技术细节,包括数据库读写、外部服务调用、消息发送等。它实现领域层定义的接口,使领域层对技术细节保持无感知。
MVC 对照: 对应并升级了 MVC 的 DAO/Mapper 层。MVC 中 DAO 被 Service 直接依赖,换数据库就必须修改 Service;DDD 中基础设施层实现领域层定义的仓储接口,领域层只感知接口,底层切换 JPA 或 MongoDB 对领域逻辑无影响。此外,外部 HTTP 调用(如优惠券服务)也集中在此层通过防腐层适配,防止第三方 API 的变动渗透到业务代码中。
包含的内容:
- 仓储实现(JPA / MyBatis)
- 外部服务适配器(防腐层 ACL)
- 消息生产者
- 缓存操作
java
// ✅ 基础设施层:仓储实现
// 实现领域层定义的接口,使用 JPA 做具体的持久化
@Repository
public class OrderRepositoryImpl implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderConverter converter; // PO ↔ DO 转换器
@Override
public Optional<Order> findById(OrderId orderId) {
return jpaRepository.findById(orderId.getValue())
.map(converter::toDomain); // PO → 领域对象
}
@Override
public void save(Order order) {
OrderPO po = converter.toPO(order); // 领域对象 → PO
jpaRepository.save(po);
// 发布领域事件(在事务提交后)
publishDomainEvents(order.getDomainEvents());
order.clearDomainEvents();
}
}
// ✅ 基础设施层:防腐层(外部优惠券服务适配)
@Component
public class CouponServiceAdapter implements CouponQueryPort {
private final CouponFeignClient couponFeignClient;
@Override
public List<Coupon> getApplicableCoupons(CustomerId customerId) {
// 调用外部优惠券服务,将其返回的 DTO 转换为领域对象
List<CouponDTO> dtos = couponFeignClient.getCoupons(customerId.getValue());
return dtos.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
private Coupon toDomain(CouponDTO dto) {
return new Coupon(
CouponId.of(dto.getId()),
Money.cny(dto.getAmount()),
Money.cny(dto.getMinOrderAmount()),
dto.getExpireAt()
);
}
}
依赖倒置原则
依赖倒置是 DDD 四层架构能够成立的根基。传统 MVC 中高层模块直接依赖低层模块,而 DDD 通过接口反转依赖方向。
依赖倒置的实际效果:
| 场景 | MVC 的代价 | DDD 的代价 |
|---|---|---|
| MySQL 换 MongoDB | 改所有 Service 中的查询逻辑 | 只新建一个仓储实现类 |
| 优惠券服务换供应商 | 改所有引用了优惠券的 Service | 只改防腐层适配器 |
| 单元测试领域逻辑 | 必须 Mock DAO,测试复杂 | 直接 new Order() 测试,无需 Mock |
| 多种持久化方案并存 | 几乎无法实现 | 不同环境注入不同仓储实现 |
代码包结构规范
plain
com.example.order/
│
├── interfaces/ # 接口层(所有外部/内部请求入口)
│ ├── rest/ # HTTP Controller
│ │ ├── OrderController.java # DTO→Command(入参)、DO→VO(出参)均在此完成
│ │ ├── request/
│ │ │ └── PlaceOrderRequest.java # HTTP 请求 DTO(前端入参)
│ │ └── response/
│ │ └── PlaceOrderResponse.java # HTTP 响应 VO(含 from() 静态工厂做 DO→VO 转换)
│ ├── rpc/ # RPC 服务端(如 gRPC、Dubbo)
│ │ └── OrderRpcService.java
│ ├── consumer/ # MQ 消费者
│ │ └── InventoryEventConsumer.java # 消息队列监听器
│ └── job/ # 定时任务入口(触发源是时间信号,与 rest/ consumer/ 对称)
│ └── OrderTimeoutJob.java # @Scheduled / @XxlJob,只调用 ApplicationService
│
├── application/ # 应用层
│ ├── service/ # 应用服务(推荐,核心用例编排)
│ │ └── OrderApplicationService.java # 应用服务示例
│ ├── command/ # 命令对象(可选,推荐不可变)
│ │ ├── PlaceOrderCommand.java
│ │ └── PlaceOrderCommandHandler.java
│ ├── query/ # 查询对象与处理器(可选)
│ │ ├── GetOrderQuery.java
│ │ └── GetOrderQueryHandler.java
│ └── event/ # 事件监听器(可选,若有业务编排需求)
│ └── OrderEventListener.java
│
│ # 说明:
│ # - 应用服务(ApplicationService)是应用层的核心,推荐单独 service/ 包管理。
│ # - command/、query/ 目录为可选,命令/查询对象不是必须,简单场景可直接用 DTO。
│ # - 事件监听器可放在 event/,但如仅做协议适配也可放接口层。
│
├── domain/ # 领域层(核心,不依赖框架)
│ ├── model/
│ │ ├── order/
│ │ │ ├── Order.java # 聚合根
│ │ │ ├── OrderItem.java # 实体
│ │ │ ├── OrderId.java # 值对象
│ │ │ └── OrderStatus.java # 值对象(枚举)
│ │ └── shared/
│ │ ├── Money.java # 通用值对象
│ │ └── Address.java # 通用值对象
│ ├── service/
│ │ ├── PricingDomainService.java # 接口
│ │ └── PricingDomainServiceImpl.java
│ ├── repository/
│ │ └── OrderRepository.java # 仓储接口(领域层定义)
│ ├── event/
│ │ ├── OrderPlacedEvent.java # 领域事件
│ │ └── OrderPaidEvent.java
│ └── exception/
│ └── OrderException.java # 领域异常
│
└── infrastructure/ # 基础设施层
├── persistence/
│ ├── OrderRepositoryImpl.java # 仓储实现(调用 Converter 完成 DO↔PO 转换)
│ ├── po/
│ │ └── OrderPO.java # 持久化对象(JPA Entity,与表结构一一对应)
│ ├── dao/
│ │ └── OrderJpaRepository.java # Spring Data JPA 接口
│ └── converter/
│ └── OrderConverter.java # DO↔PO 转换(值对象展开/重建、枚举互转等复杂映射)
├── acl/ # 防腐层
│ ├── CouponServiceAdapter.java # 外部优惠券服务适配器
│ └── feign/
│ └── CouponFeignClient.java
└── config/ # 调度框架配置(始终属基础设施层)
└── SchedulingConfig.java # @EnableScheduling / Quartz / XXL-Job 执行器配置
【补充说明】
- 应用服务(ApplicationService)是应用层的核心,推荐单独 service/ 包管理。
- command/、query/ 目录为可选,命令/查询对象不是必须,简单场景可直接用 DTO。
- 事件监听器(EventListener)可放在应用层 event/,但如仅做协议适配也可放接口层。
- 命令对象(Command)不是强制要求,若业务简单可直接用 DTO,复杂用例推荐使用不可变 Command 对象以提升可维护性和可测试性。
对象模型与转换
DDD 各层使用不同的对象模型,每种模型都有明确的用途,不能混用。
| 对象类型 | 所在层 | 用途 | 特征 |
|---|---|---|---|
| Request DTO | 接口层 | 接收 HTTP 请求参数 | 含校验注解,字段类型为基本类型 |
| Command / Query | 应用层 | 应用服务的入参 | 不可变,字段类型为值对象 |
| Domain Object (DO) | 领域层 | 承载业务逻辑 | 有行为方法,不含框架注解 |
| Persistence Object (PO) | 基础设施层 | 数据库表映射 | 含 @Entity,无业务方法 |
| Query DTO | 应用层出参 | 查询结果传递 | 扁平结构,只读 |
| Response VO | 接口层 | 返回给前端 | 按前端需要组装 |
核心原则: 领域对象(DO)绝对不能出现 @Entity、@Column 等持久化注解,也不能直接作为 HTTP 响应返回给前端。对象转换集中在 Converter 中处理,不要在各处散落。
对象类型速查:
- Request(请求对象/Request DTO):接收前端或外部接口的参数,通常用于 Controller 入参。
- Command(命令对象):封装一次业务操作的所有参数,驱动应用服务执行业务用例,如下单、支付。
- DO(领域对象/Domain Object):承载业务规则的核心对象,如 Order、Product,包含行为方法。
- PO(持久化对象/Persistence Object):数据库表的映射对象,带有 JPA 等持久化注解。
- VO(视图对象/View Object):返回给前端的响应对象,按前端需求组装。
- DTO(数据传输对象/Data Transfer Object):用于跨层或跨服务传递数据,常见于查询结果、远程调用。
完整转换流与 Converter 的位置:数据流经各层时,每一步转换的发起方和实现方式如下:
为什么只有 DO**↔**PO 需要专门的 Converter? Request→Command 和 DO→VO 的字段通常是扁平的一对一映射,直接在调用处写即可;DO↔PO 则面对值对象展开(
Money拆成amount/currency两列)、枚举与字符串互转、嵌套集合递归处理等复杂场景,提取到独立的 Converter 类既避免了代码重复,也让仓储实现保持干净。
| 转换方向 | 发生在哪层 | 实现方式 |
|---|---|---|
| Request DTO → Command | 接口层(Controller) | inline 组装(new PlaceOrderCommand(...)),字段简单时无需专门 Converter |
| Command → DO | 应用层(加载或创建聚合) | 应用服务调用 Order.create(...) 或通过仓储查询后返回 DO |
| DO ↔ PO | 基础设施层(Converter) | 专门的 OrderConverter 类,位于 infrastructure/persistence/converter/;负责值对象展开/重建、嵌套对象递归映射等复杂转换 |
| DO → VO | 接口层(Controller) | 静态工厂方法(PlaceOrderResponse.from(result))或 inline 组装 |
| DO → Query DTO | 应用层(查询处理器) | 查询结果组装,通常用构造器或 MapStruct |
与 MVC 分层对比
| 维度 | MVC | DDD 四层架构 |
|---|---|---|
| 业务逻辑位置 | Service 层(所有逻辑混在一起) | 领域层(聚合根 + 领域服务) |
| 测试难度 | 需要 Mock 大量依赖 | 领域层可纯 Java 单元测试 |
| 换数据库成本 | 改动 Service 层 | 只改基础设施层实现 |
| 复杂度增长 | Service 线性膨胀 | 各层独立演化,复杂度可控 |
| 团队协作 | 边界模糊,容易冲突 | 层次清晰,可按层分工 |
DDD 架构演进:从四层到六边形
四层架构解决了 MVC 大部分问题,但领域层仍处于"中间夹层"位置------上有应用层,下有基础设施层。随着外部依赖增多,"基础设施层"的职责越来越宽泛:数据库读写、MQ 客户端、第三方 HTTP 调用全部堆在一处,哪些是"被动实现"(数据库)、哪些是"主动收入"(HTTP/MQ 入口)并不分明。
六边形架构(Hexagonal Architecture,又称 Ports & Adapters) 是对依赖倒置原则的彻底贯彻:将领域核心置于正中央,所有外部交互都通过**端口(Port)定义契约,通过适配器(Adapter)**实现技术细节,领域层对一切框架和外部系统完全无感知。
演进关系可以理解为:四层架构 → 把基础设施层按"主动/被动"拆成两侧 → 领域居中的六边形。
三种架构形式对比
| 架构 | 别名 | 适用场景 | 核心思想 |
|---|---|---|---|
| 传统四层架构 | Layered Architecture | 中小型项目,入门首选 | 依赖只向下,领域层居中 |
| 六边形架构 ⭐ | Ports & Adapters | 中大型项目,强调可测试性 | 领域在中心,外部通过端口适配 |
| 整洁架构 | Clean Architecture | 大型/长期演进项目 | 同心圆,依赖只向内 |
六边形架构结构图
四层 vs 六边形:核心差异
| 对比维度 | 传统四层架构 | 六边形架构 |
|---|---|---|
| 领域层位置 | 中间层,被上下夹着 | 正中心,完全独立 |
| 基础设施划分 | 统一放"基础设施层" | 明确分为入站/出站两侧适配器 |
| 可测试性 | 需加载 Spring 容器 | 直接 new 对象即可测试 |
| 外部依赖 | 领域层可能间接依赖框架 | 领域层零依赖,纯 Java |
| 替换成本 | 换数据库需改多处 | 只换对应 Driven Adapter 实现 |
| 适合场景 | 入门、中小型项目 | 中大型、长期维护项目 |
包结构演进参考
本系列文档示例以传统四层架构组织包结构,目的是让层次直观、入门门槛更低。如需向六边形架构演进,包结构调整思路如下:
plain
传统四层包结构 六边形包结构
com.example.order/ com.example.order/
├── interfaces/ ⇒ ├── adapters/
│ ├── rest/ │ ├── inbound/
│ └── consumer/ │ │ ├── rest/
├── application/ │ │ └── messaging/
├── domain/ │ └── outbound/
└── infrastructure/ │ ├── persistence/
├── persistence/ │ └── acl/
└── acl/ ├── application/ ← 不变
└── domain/ ← 不变
两种包结构下,领域层和应用层的代码完全相同,只有适配器的组织方式不同。
分层粒度的实践权衡
"领域层不依赖任何框架"是理想目标,并非所有项目都需要严格执行。DO/PO 分离、自定义仓储接口等做法引入额外的转换代码,在业务简单的项目中反而增加维护负担。核心问题是:在当前项目规模和复杂度下,隔离的收益是否大于转换的成本?
| 策略 | 仓储层写法 | 领域对象写法 | 适合场景 |
|---|---|---|---|
| 简化版 | 直接注入 JpaRepository |
@Entity 实体,业务逻辑写在 Service |
CRUD 为主,业务规则极少 |
| 均衡版 | 领域层 extends JpaRepository,无实现类 |
@Entity 兼作领域对象,添加行为方法 |
业务中等复杂,领域模型与表结构基本一致 |
| 严格版 | 领域层定义纯接口,基础设施层实现 | DO 与 PO 分离,Converter 转换 | 业务极复杂,表结构与领域模型差异大,高可测性要求 |
均衡版:Spring Data JPA 接口定义在领域层
多数中型项目采用这种方式:领域层直接 extends JpaRepository,@Entity 实体上添加行为方法,基础设施层不需要写仓储实现类 。领域层会引入 spring-data-commons(只是接口定义,不含 JPA 具体实现),但换数据库时的改动范围仍远小于传统 MVC。
java
// ✅ 均衡版:领域层仓储接口直接继承 JpaRepository
// 代价:引入 spring-data-commons,但无需在基础设施层写实现类
package com.example.order.domain.repository;
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerId(Long customerId);
List<Order> findByStatusAndCreatedAtBefore(OrderStatus status, LocalDateTime time);
}
// ✅ @Entity 标注在领域对象上,同时添加业务行为方法
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
// 业务行为仍然封装在领域对象内部
public void pay(Payment payment) {
if (this.status != OrderStatus.PENDING) {
throw new OrderException("只有待支付订单才能支付");
}
this.status = OrderStatus.PAID;
}
public Money calculateTotal() {
return items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.ZERO, Money::add);
}
}
均衡版需注意:
@Entity使领域对象与 JPA 代理机制绑定。使用延迟加载(FetchType.LAZY)时,若在事务外访问关联对象会抛出LazyInitializationException。团队需理解 JPA 的事务上下文,避免在 Service 方法外访问懒加载集合。
何时必须严格分离(DO / PO 分开)
以下场景中,严格分离带来的收益能覆盖 Converter 转换的成本:
| 场景 | 原因 |
|---|---|
| 领域模型与表结构差异较大 | 如值对象 Money(amount, currency) 在数据库中平铺为两列;Address 对象需跨表存储 |
| 遗留数据库无法修改 | PO 必须匹配历史表结构,DO 保持干净的领域语义 |
| 同一聚合需多种持久化 | 如订单写 MySQL、同步 Elasticsearch,两套 PO 共享同一 DO |
| 高度可测试性要求 | 领域服务单元测试完全不引入数据库依赖,new Order() 即可测试 |
对整本书示例的说明
本系列文档采用严格版 作为讲解示例,目的是将每一层的职责和边界讲清楚。实际项目中可根据上述判断标准选择合适的粒度,不必照单全收。DDD 的核心是业务建模思想,DO/PO 分离只是其中一种实现手段。
定时任务的层次归属
定时任务(Scheduled Task)在 DDD 四层架构中的归属层次,表面上存在争议,但回到四层架构的核心规则来分析,答案其实是明确的。
先看依赖方向规则
DDD 四层架构的基本规则是:依赖方向向下(指向领域层)。
plain
接口层 → 应用层 → 领域层 ← 基础设施层
以此规则对定时任务做一个简单判断:
结论先行: 在严格遵循四层依赖方向的前提下,定时任务入口放接口层才是架构正确的选择。
三种观点
观点一:放在接口层(依赖方向正确的做法)
接口层是所有"外部触发信号驱动应用执行"的入口层。定时任务、HTTP 请求、MQ 消息,触发机制不同,但代码结构完全一致:接收触发信号 → 调用应用层用例 → 不含任何业务逻辑。把它们并列放在接口层,既符合依赖方向,也符合职责对称:
这三类入口的代码结构完全一致:接收外部触发信号 → 调用应用层用例 → 不含任何业务逻辑。
| 入口类型 | 触发源 | 所在包 | 调用应用层 |
|---|---|---|---|
OrderController |
HTTP 请求 | interfaces/rest/ |
OrderApplicationService.placeOrder() |
InventoryEventConsumer |
MQ 消息 | interfaces/consumer/ |
OrderApplicationService.handleInventory() |
OrderTimeoutJob |
时间信号 | interfaces/job/ |
OrderApplicationService.cancelTimeoutOrders() |
java
// ✅ 接口层正确做法:interfaces/job/
@Component
public class OrderTimeoutJob {
private final OrderApplicationService orderApplicationService;
@Scheduled(fixedDelay = 60_000)
public void run() {
// 接口层 → 应用层,依赖方向向下,与 Controller 完全对称
orderApplicationService.cancelTimeoutOrders();
}
}
观点二:放在基础设施层(常见但存在层次越界)
实际项目中也常见将定时任务放在基础设施层的 job/ 包,理由是"@Scheduled/@XxlJob 与 @Mapper/@FeignClient 同属框架注解,因此归基础设施层"。这种视角有一定技术一致性,但带来了明显的层次越界问题:
问题所在: 基础设施层的 Job 要调用
ApplicationService,即基础设施层 → 应用层,方向向上,直接违反四层架构的依赖方向规则。Spring IoC 技术上允许这样注入,但不代表分层逻辑上正确。
java
// ⚠️ 基础设施层方案:infrastructure/job/(技术可行,但依赖方向违规)
@Component
public class OrderTimeoutJob {
private final OrderApplicationService orderApplicationService; // 基础设施层依赖应用层,方向向上
@Scheduled(fixedDelay = 60_000)
public void run() {
orderApplicationService.cancelTimeoutOrders();
}
}
这种做法之所以普遍存在,是因为很多团队不严格审查层间依赖方向,Spring 也不会在运行时报错,久而久之就成为了惯例。但层次理论上的违规客观存在。
观点三:放在应用层(少数,不推荐)
极少数观点认为定时任务本质是"系统内部周期性触发的用例",可直接放在应用层。这种做法存在明显问题:应用层会感知调度框架(如 @Scheduled),与框架产生直接耦合;应用层的职责从"编排用例"扩展到"感知触发机制",破坏了层次职责的纯粹性,一般不推荐。
各观点对比
| 观点 | 归属层 | 依赖方向 | 分层合理性 | 实际使用情况 |
|---|---|---|---|---|
| 依赖规则正确 | 接口层 interfaces/job/ |
接口层→应用层 ✅ | 完全符合 | 严格四层/六边形架构团队 |
| 常见但越界 | 基础设施层 infrastructure/job/ |
基础设施层→应用层 ❌ | 依赖方向违规 | 不严格审查层间依赖的团队 |
| 不推荐 | 应用层 | 应用层感知框架注解 ❌ | 职责混乱 | 极少 |
实践建议
两个不变的约束:
- Job 类本身不写业务逻辑,只调用应用层服务。
- 调度框架配置 (Quartz 调度器、JobStore、XXL-Job 注册中心等)始终放在基础设施层的
config/。
推荐选择:
将 Job 入口类放在接口层的 job/ 子包 ,与 rest/、consumer/ 并列,依赖方向完全正确,架构语义清晰。如果项目已有 infrastructure/job/ 的历史约定,理解其层次越界的本质后,按团队实际情况决定是否迁移,保持内部一致即可。
调度框架配置(
@EnableScheduling、Quartz Bean、XXL-Job 执行器)始终放基础设施层config/,这一点不受 Job 入口归属哪层的影响。
常见误区
| ❌ 误区 | ✅ 正确做法 |
|---|---|
把 @Entity 标注在领域对象上 |
严格版中需要 DO/PO 分离;均衡版可接受,但需注意 JPA 延迟加载陷阱(见"分层粒度的实践权衡") |
应用服务中写 if (status == PENDING) 业务判断 |
状态判断放入聚合根方法(order.pay() 内部判断) |
领域服务注入 OrderRepository |
领域服务不调用仓储,由应用服务加载聚合后传入 |
| Controller 直接返回领域对象 | 领域对象通过 Converter 转为 Response VO 再返回 |
| 所有对象都用 DTO 命名 | 按用途区分:Request / Command / DO / PO / VO / DTO |
领域层引入 Spring 注解(@Autowired 等) |
领域层不依赖任何框架,通过构造器注入 |
领域事件基类 extends ApplicationEvent |
DomainEvent 写成纯 POJO;DomainEventPublisher 接口定义在领域层,基础设施层实现并调用 springPublisher.publishEvent() |