跳到内容

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

架构图画出了整齐的同心圆,代码里的业务模块却仍直接导入数据库和 Web 框架;阿花决定沿依赖方向逐条核对。

洋葱架构、六边形架构和 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
     外部细节                         核心政策

穿过边界的是稳定数据

不要让内层接收 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(Instant settledAt) implements SettleMatchResult {}
}

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

输出边界何时有价值

简单用例直接返回结果对象即可。只有当用例需要主动控制多阶段呈现,或同一输出存在多种呈现策略时,才考虑输出端口:

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

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

用构建边界强制规则

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

text
domain                 不依赖其他业务模块
application            依赖 domain
adapters:web           依赖 application
adapters:persistence   依赖 application、domain
bootstrap              依赖全部模块并负责装配

还可以用架构测试阻止回退:

java
@ArchTest
static final ArchRule domain_must_not_depend_on_adapters =
    noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("..adapter..", "org.springframework..", "jakarta.persistence..");

架构测试只能检查可编码规则。它无法判断一个名为 DomainService 的类是否偷偷做了协议转换,也无法替代评审。

按圈层制定测试策略

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

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

典型失败方式

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

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

仓储接口复制 ORM API

save(Entity)findAll(Pageable) 若完全暴露框架概念,核心只是把外层 API 换了一个名字。端口应表达核心真正需要的查询和一致性语义。

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

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

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

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

架构是否有效的验收问题

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

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

参考资料

Built with VitePress | Software Systems Atlas