Skip to content

分层约定与架构守卫 ​

分层的价值不在于目录长什么样,而在于依赖方向被强制约束。这篇定义 DDK 的分层规则,以及怎么用 ArchUnit 让规则在 CI 里生效而不只是写在文档里。

一、依赖方向 ​

四层结构的允许依赖:

DDK four-layer request flow

用文字说清楚,四条规则:

  1. adapter 只能依赖 application(以及 domain 的只读模型,用于组装响应)
  2. application 只能依赖 domain
  3. domain 不依赖任何其他层,也不依赖任何框架
  4. infrastructure 依赖 domain(实现 domain 定义的接口),不被 domain 依赖

第 4 条是依赖倒置的落点:domain/acl 里定义 UserRepository 接口,infrastructure/acl/impl 里提供实现,运行时由 Spring 注入。依赖在编译期是 infrastructure → domain,在运行期是 domain 使用 infrastructure 的实现。

二、domain 层的硬约束 ​

这是整套约定里唯一不能妥协的一条:领域层不得依赖任何框架。

不允许出现在 domain 包里的东西:

java
import org.springframework.*;              // Spring
import com.baomidou.mybatisplus.*;         // MyBatis-Plus
import jakarta.persistence.*;              // JPA
import com.fasterxml.jackson.*;            // Jackson
import jakarta.validation.*;               // Bean Validation
import org.apache.ibatis.*;                // MyBatis

为什么连 Jackson 和 Bean Validation 也不行?因为它们会把「序列化格式」和「输入校验」这两个外部关注点带进领域模型。@NotBlank 表达的是「HTTP 请求里这个字段不能为空」,而领域不变量应该由构造器和方法自己保证——两者看起来像,实际是不同层的职责。

判断标准很实用:领域层的单元测试应该不需要启动任何容器、不需要 mock 任何框架对象。

java
// 领域层测试应该长这样,纯 Java
@Test
void 已禁用的用户不能再次禁用() {
    User user = User.register("alice", "13800000000");
    user.disable();
    assertThatThrownBy(user::disable)
            .isInstanceOf(BusinessException.class);
}

如果一个领域层的测试需要 @SpringBootTest,那说明分层已经漏了。

三、各层职责与禁忌 ​

adapter ​

职责:协议适配。HTTP 参数绑定、MQ 消息反序列化、响应包装。

禁忌:

  • 不写业务判断。if (order.getStatus() == PAID) 这种代码出现在 Controller 里就是漏层了
  • 不直接调用 Repository。必须经过 application
  • 不直接返回领域实体。用 application 层的 DTO,否则领域模型的字段变更会直接影响 API 契约

application ​

职责:用例编排。事务边界、权限校验、调用领域对象、发布事件。

禁忌:

  • 不写业务规则。规则属于 domain。判断标准:如果一段 if 表达的是业务约束(而不是流程分支),它应该在实体或领域服务里
  • 不出现 SQL 或 PO 类型
  • 事务里不做 RPC 调用

一个典型的漏层信号:

java
// 坏:业务规则漏到了应用层
@Transactional
public void cancel(Long orderId) {
    Order order = orderRepository.find(orderId);
    if (order.getStatus() != OrderStatus.PENDING_PAYMENT
            && order.getStatus() != OrderStatus.PAID) {
        throw new BusinessException(OrderError.NOT_CANCELLABLE);
    }
    order.setStatus(OrderStatus.CANCELLED);
    orderRepository.update(order);
}

// 好:规则回到实体,应用层只编排
@Transactional
public void cancel(Long orderId, String reason) {
    Order order = orderRepository.find(orderId);
    if (order == null) {
        throw new BusinessException(OrderError.NOT_FOUND, orderId);
    }
    order.cancel(reason);          // 状态机约束在 Order 里
    orderRepository.update(order);
}

domain ​

职责:业务规则、不变量、状态机、领域事件。

禁忌:

  • 不依赖框架(见第二节)
  • 不出现 setter。状态变更通过有业务含义的方法(cancel()、markPaid())而不是 setStatus()
  • 不感知持久化。实体不知道自己会被存到哪里

infrastructure ​

职责:实现 domain 定义的接口,封装技术细节。

禁忌:

  • 不写业务规则
  • PO 不外泄。PO 只在 infrastructure 内部流转,出口一律转成领域实体

四、用 ArchUnit 强制执行 ​

规则写在文档里没有约束力,必须进 CI。ddk-archguard-starter 提供了现成的规则常量。

4.1 引入 ​

xml
<dependency>
    <groupId>com.ddk</groupId>
    <artifactId>ddk-archguard-starter</artifactId>
    <version>${ddk.version}</version>
    <scope>test</scope>       <!-- 必须显式声明 test,否则 ArchUnit 会传递进运行期 -->
</dependency>

<scope>test</scope> 不能省。该模块的 archunit-junit5-api 目前是 compile 作用域,不加这行会把 ArchUnit 带进业务模块的编译和运行期依赖。

4.2 写一个守卫测试 ​

java
@AnalyzeClasses(packages = "com.yourcompany.yourapp",
                importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {

    @ArchTest
    static final ArchRule 分层依赖 = CommonArchRules.LAYERED_ARCHITECTURE_RULE;

    /** domain 层不得依赖任何框架 */
    @ArchTest
    static final ArchRule 领域层保持纯净 =
            noClasses().that().resideInAPackage("..domain..")
                    .should().dependOnClassesThat()
                    .resideInAnyPackage(
                            "org.springframework..",
                            "com.baomidou..",
                            "com.fasterxml.jackson..",
                            "jakarta.persistence..",
                            "org.apache.ibatis..")
                    .because("领域层必须能在没有容器的情况下被单元测试");

    /** PO 不得离开 infrastructure */
    @ArchTest
    static final ArchRule PO不外泄 =
            noClasses().that().resideOutsideOfPackage("..infrastructure..")
                    .should().dependOnClassesThat().resideInAPackage("..orm.po..")
                    .because("持久化对象是基础设施的实现细节");

    /** Controller 不得直接使用 Repository */
    @ArchTest
    static final ArchRule 控制器不直连仓储 =
            noClasses().that().resideInAPackage("..adapter..")
                    .should().dependOnClassesThat().resideInAPackage("..acl..")
                    .because("adapter 必须经过 application 层");

    /** 领域实体不得有 setter */
    @ArchTest
    static final ArchRule 实体没有setter =
            noMethods().that().areDeclaredInClassesThat()
                    .resideInAPackage("..domain.model.entity..")
                    .should().haveNameMatching("set[A-Z].*")
                    .because("状态变更应通过有业务含义的方法");
}

前四条建议所有项目都加上。第五条(禁止 setter)比较严格,团队没准备好可以先不启用。

4.3 当前规则状态 ​

CommonArchRules 现在提供三条规则常量。

分层方向:

java
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure")
.whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Application")
  • Infrastructure 可以访问 Domain,用来实现 Domain 定义的仓储接口
  • Domain 不能访问 Infrastructure,依赖倒置方向不会被放过
  • Application 目前仍被允许访问 Infrastructure,后续可以根据团队严格程度继续收紧

领域层纯度,正是第二节那条硬约束的可执行版本:

java
public static final ArchRule DOMAIN_MUST_NOT_DEPEND_ON_FRAMEWORKS = noClasses()
        .that().resideInAPackage("..domain..")
        .should().dependOnClassesThat().resideInAnyPackage(
                "org.springframework..", "com.baomidou..", "com.fasterxml.jackson..",
                "org.apache.ibatis..", "jakarta.persistence..", "javax.persistence..")
        .because("领域模型必须能在没有容器的情况下被单元测试,持久化细节不得渗入业务模型");

第三条 DOMAIN_MUST_NOT_DEPEND_ON_OUTER_LAYERS 单独约束依赖方向,给只想管领域层纯度、不想引入完整分层规则的项目按需选用。

另有一条 DDK_INTERNALS_MUST_NOT_BE_USED:各 starter 的实现类放在 com.ddk.<starter>.starter.internal 包里,不属于公开 API,应用代码依赖它们时架构测试失败。需要定制时,覆盖对应的 Bean 或实现公开的扩展接口(如 CacheMetrics、CacheInvalidationPublisher)。示例项目与 archetype 生成的项目默认启用这条规则。

DDK 自己也吃这份狗粮:ddk-core 的 DomainPackagePurityTest 对 com.ddk.core.domain 断言得更严——只允许依赖 JDK、JSpecify 空安全注解与自身,连 Lombok、Hutool 都不行。

每条规则都有「构造一个违规类,断言规则能抓到它」的测试:ddk-archguard-starter 的测试目录里为三层、四层、框架依赖与 internal 包分别准备了合规与违规的样例类。

五、三层结构的差异 ​

三层把 application 和 domain 合并成 business:

adapter ──→ business
              ↑
infrastructure┘

约束相应放宽:business 层内部允许业务规则和用例编排混在一起。但两条不变:

  1. business 层依然不应依赖框架(除了事务注解这类必要的妥协)
  2. PO 依然不得离开 infrastructure

什么时候选三层:领域逻辑简单、以 CRUD 为主、团队规模小。什么时候选四层:领域规则复杂、需要严格隔离业务与技术、多人协作。

判断标准不是项目大小,而是领域逻辑的复杂度。一个几十万行的 CRUD 系统用三层比四层合适,一个几千行但规则密集的计费系统反过来。

六、约定落地的顺序 ​

新项目直接按四层走。老项目改造建议这个顺序,每步都能独立产生价值:

  1. 先加 ArchUnit 测试但允许失败(@ArchIgnore 掉现有违规),把违规数量做成一个可见的数字
  2. 禁止新增违规——CI 里比较违规数量,只允许下降
  3. 先修「PO 外泄」,这类违规通常最多且最好修
  4. 再修「Controller 直连 Repository」
  5. 最后清理 domain 层的框架依赖,这一步最难,往往需要重写实体

不要试图一次改完。分层约定的价值在于阻止腐化,而不是一次性清理历史债。

相关文档 ​

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权