分层约定与架构守卫
分层的价值不在于目录长什么样,而在于依赖方向被强制约束。这篇定义 DDK 的分层规则,以及怎么用 ArchUnit 让规则在 CI 里生效而不只是写在文档里。
一、依赖方向
四层结构的允许依赖:
用文字说清楚,四条规则:
- adapter 只能依赖 application(以及 domain 的只读模型,用于组装响应)
- application 只能依赖 domain
- domain 不依赖任何其他层,也不依赖任何框架
- infrastructure 依赖 domain(实现 domain 定义的接口),不被 domain 依赖
第 4 条是依赖倒置的落点:domain/acl 里定义 UserRepository 接口,infrastructure/acl/impl 里提供实现,运行时由 Spring 注入。依赖在编译期是 infrastructure → domain,在运行期是 domain 使用 infrastructure 的实现。
二、domain 层的硬约束
这是整套约定里唯一不能妥协的一条:领域层不得依赖任何框架。
不允许出现在 domain 包里的东西:
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
@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 调用
一个典型的漏层信号:
// 坏:业务规则漏到了应用层
@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 引入
<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 写一个守卫测试
@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 现在提供三条规则常量。
分层方向:
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure")
.whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Application")- Infrastructure 可以访问 Domain,用来实现 Domain 定义的仓储接口
- Domain 不能访问 Infrastructure,依赖倒置方向不会被放过
- Application 目前仍被允许访问 Infrastructure,后续可以根据团队严格程度继续收紧
领域层纯度,正是第二节那条硬约束的可执行版本:
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 层内部允许业务规则和用例编排混在一起。但两条不变:
- business 层依然不应依赖框架(除了事务注解这类必要的妥协)
- PO 依然不得离开 infrastructure
什么时候选三层:领域逻辑简单、以 CRUD 为主、团队规模小。什么时候选四层:领域规则复杂、需要严格隔离业务与技术、多人协作。
判断标准不是项目大小,而是领域逻辑的复杂度。一个几十万行的 CRUD 系统用三层比四层合适,一个几千行但规则密集的计费系统反过来。
六、约定落地的顺序
新项目直接按四层走。老项目改造建议这个顺序,每步都能独立产生价值:
- 先加 ArchUnit 测试但允许失败(
@ArchIgnore掉现有违规),把违规数量做成一个可见的数字 - 禁止新增违规——CI 里比较违规数量,只允许下降
- 先修「PO 外泄」,这类违规通常最多且最好修
- 再修「Controller 直连 Repository」
- 最后清理 domain 层的框架依赖,这一步最难,往往需要重写实体
不要试图一次改完。分层约定的价值在于阻止腐化,而不是一次性清理历史债。