跳到内容

第 6 卷 / 第 12 章 / 第 2 课

12.2 依赖规则:Clean Architecture 如何落到代码 ​

洋葱架构、六边形架构和 Clean Architecture 的图形不同,核心约束相近:业务政策位于内部,源代码依赖只能指向更稳定、更内层的政策。

这条依赖规则约束的是编译期认识关系,不是运行时调用方向。上一课由应用服务调用核心拥有的仓储端口,运行时再进入数据库适配器;源码依赖仍从适配器指向端口。依赖规则不要求领域实体亲自持有仓储。

四类代码,不是四个必选文件夹 ​

Clean Architecture 常见的同心结构可以理解为:

  1. Enterprise Business Rules:跨用例稳定的实体和业务规则;
  2. Application Business Rules:具体用例的编排;
  3. Interface Adapters:Controller、Presenter、Gateway 和映射;
  4. Frameworks & Drivers:Web 框架、数据库驱动、消息中间件。

层数可以调整。重点是外层细节不能成为内层政策的前提。

text
frameworks → adapters → application → domain
     外部细节                         核心政策

穿过边界的是稳定数据 ​

下面 Java 21 片段使用项目定义的 ID、积分变化和拒绝原因类型,输入校验与结果不变量仍需在这些类型的构造入口落实。不要让内层接收 HttpServletRequest、JPA Entity、Kafka ConsumerRecord 或供应商 SDK 对象。边界数据应是内层可以拥有的普通结构:

java
public record SettleMatchCommand(MatchId matchId, PlayerId winnerId) {}

public sealed interface SettleMatchResult {
    record Settled(ScoreDelta delta) implements SettleMatchResult {}
    record AlreadySettled(java.time.Instant settledAt) implements SettleMatchResult {}
    record Rejected(Rejection reason) implements SettleMatchResult {}
}

这让协议升级、表结构变化和 SDK 替换被限制在适配器中。边界两侧都需要映射并不表示设计失败,而是耦合成本被显式支付。

输出边界何时有价值 ​

简单用例直接返回结果对象即可。当结果需要交给可替换的 presenter,或用例会输出多个阶段的业务结果时,可以考虑输出端口;它表达业务结果,不指挥 DOM 或页面布局:

java
interface SettleMatchOutput {
    void settled(ScoreDelta delta);
    void alreadySettled(java.time.Instant settledAt);
    void rejected(Rejection reason);
}

不要为了模仿架构图给每个返回值增加 Presenter 接口。抽象应服务于真实变化轴。

用构建边界强制规则 ​

目录约定靠自觉,模块依赖由构建系统约束更可靠:

text
domain                 不依赖本应用的外层模块
application            依赖 domain
adapters:web           依赖 application
adapters:persistence   依赖 application、domain
bootstrap              依赖全部模块并负责装配

构建图只列直接依赖;若外层类型在公开签名中暴露领域类型,构建配置还需明确哪些依赖可传递给调用者。不要靠偶然的测试 classpath 掩盖缺失依赖。

下面用 JUnit 5 和 ArchUnit 1.4.2 检查本例的严格边界。沿用 1.1 的 JUnit 配置,另加测试依赖:

xml
<dependency>
  <groupId>com.tngtech.archunit</groupId>
  <artifactId>archunit</artifactId>
  <version>1.4.2</version>
  <scope>test</scope>
</dependency>

将完整测试保存为 src/test/java/ArchitectureTest.java,并把 example.tournament 改成实际根包。它同时覆盖领域与应用层,识别 adapter、adapters 和本卷常用的 infrastructure 包名:

java
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

class ArchitectureTest {
    private final JavaClasses classes = new ClassFileImporter()
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
            .importPackages("example.tournament");

    @Test
    void domainDoesNotDependOnOuterLayers() {
        noClasses().that().resideInAPackage("..domain..")
                .should().dependOnClassesThat().resideInAnyPackage(
                        "..application..", "..adapter..", "..adapters..",
                        "..infrastructure..", "..api..", "..bootstrap..",
                        "org.springframework..", "jakarta.persistence..")
                .check(classes);
    }

    @Test
    void applicationDoesNotDependOnAdapters() {
        noClasses().that().resideInAPackage("..application..")
                .should().dependOnClassesThat().resideInAnyPackage(
                        "..adapter..", "..adapters..", "..infrastructure..",
                        "..api..", "..bootstrap..",
                        "org.springframework..", "jakarta.persistence..")
                .check(classes);
    }
}

先确认正确代码通过,再临时让领域类引用一个基础设施类型,检查测试确实失败;删掉该引用后应恢复通过。规则默认不允许待检查集合为空,根包写错不应被当作“架构完美”。本例要求应用层也不依赖 Spring,因此 11.1 的注解服务需要改用上一课的外层事务装饰器。

ArchUnit 分析导入的类文件依赖,不能可靠识别反射字符串、运行时配置或未导入的产物;“改了 import 文本”不是它唯一看的东西。它也无法判断一个名为 DomainService 的类是否在做协议转换,仍需评审。

按圈层制定测试策略 ​

范围主要验证常见测试
领域不变式、状态转换、计算无框架的快速单元测试
应用用例编排、失败语义、端口交互使用可信替身的用例测试
适配器映射、SQL、序列化、协议兼容数据库/消息/HTTP 集成测试
组装依赖注入、配置、关键路径少量启动与端到端测试

“核心易测”不意味着只测核心。适配器包含最容易被真实协议打脸的代码,必须在接近生产的基础设施上验证。

典型失败方式 ​

领域贫血,所有规则仍在应用服务 ​

依赖方向正确不等于模型有行为。跨多个用例稳定的不变式应由领域对象维护,否则每个入口都可能实现出不同规则。

仓储接口复制 ORM API ​

问题不在 save 这个方法名;Entity 若是自有领域类型也没有自动违规。问题在于 Pageable、ORM 代理或框架约束穿过接口,迫使核心认识外层 API。端口应表达核心真正需要的查询和一致性语义。

所有改变都要求新增一圈类型 ​

Clean Architecture 不是样板代码竞赛。低风险 CRUD 功能可以保持简单;在外部依赖多、业务规则复杂、寿命长的边界上投入更多隔离。

核心发布事件,却由适配器决定业务事实 ​

“比赛已结算”应由领域或用例决定;Kafka topic 名、重试头和序列化版本由消息适配器决定。不要反过来让消息格式定义领域事实。

架构是否有效的验收问题 ​

  • 不启动 Web 和数据库,核心规则能否运行?
  • 替换持久化实现时,领域与用例是否保持不变?
  • 外部错误是否在适配器边界被翻译为核心可理解的失败?
  • 模块依赖和架构测试能否阻止内层导入外层?
  • 团队是否能指出为灵活性支付的映射与抽象成本?

下一章从单个应用内部的依赖边界,推进到部署边界:先构造模块化单体,再判断是否值得把模块提取为独立服务。

参考资料 ​

Built with VitePress | Software Systems Atlas