05 — 分层架构与依赖倒置

知识图谱


四层架构总览

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) throwtotal = price * qty 等判断和计算,在 DDD 中全部移入领域对象自身(Order.pay()OrderItem.calculateSubtotal()),让对象携带行为而不只是数据。该层完全不依赖 Spring 和数据库,可直接 new Order() 编写纯单元测试。

包含的内容:

  • 聚合根、实体、值对象(业务规则的载体)
  • 领域服务(跨实体/跨聚合的业务操作)
  • 仓储接口(只定义接口,不关心实现)
  • 领域事件(业务事实的记录)
  • 领域异常

设计原则: 领域层的代码应该可以在没有 Spring、没有数据库的情况下独立运行和测试。"不依赖框架"的真正含义不是"只能用 JDK",而是禁止引入会将领域模型绑定到某种技术实现的依赖。以下表格给出精确的许可边界:

领域层依赖许可边界

类别 典型示例 是否允许 原因
JDK 标准库 java.utiljava.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 执行器配置

【补充说明】

  1. 应用服务(ApplicationService)是应用层的核心,推荐单独 service/ 包管理。
  2. command/、query/ 目录为可选,命令/查询对象不是必须,简单场景可直接用 DTO。
  3. 事件监听器(EventListener)可放在应用层 event/,但如仅做协议适配也可放接口层。
  4. 命令对象(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/ 基础设施层→应用层 ❌ 依赖方向违规 不严格审查层间依赖的团队
不推荐 应用层 应用层感知框架注解 ❌ 职责混乱 极少

实践建议

两个不变的约束:

  1. Job 类本身不写业务逻辑,只调用应用层服务。
  2. 调度框架配置 (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()

相关推荐
Jasonakeke2 小时前
SpringBoot自动配置原理揭秘
java·spring boot·后端
lauo3 小时前
从FunloomAI到ibbot:当你的手机不再是“手机”,而是你的AI副脑和生产节点
人工智能·智能手机·架构·开源·github
IT_陈寒3 小时前
Vite热更新失灵?你可能漏了这个配置
前端·人工智能·后端
零壹AI实验室3 小时前
阶跃星辰Step 3.7 Flash开源实测:196B MoE架构,400 tokens/s是噱头还是真性能?
架构
uzong3 小时前
面试官:如何做好架构设计
后端·架构
Cosolar4 小时前
QwenPaw Agent 实现原理深度剖析
后端·面试·架构
百珏4 小时前
个人理解的AI Code Review 架构的三代演进
架构·aigc·ai编程
Sincerelyplz4 小时前
【AI会议纪要实践】mapReduce、RAG 与结构化输出
java·后端·agent
Ailrid4 小时前
设计模式——行为型设计模式:阅读笔记与个人思考
架构