12.2 依赖规则:Clean Architecture 如何落到代码
架构图画出了整齐的同心圆,代码里的业务模块却仍直接导入数据库和 Web 框架;阿花决定沿依赖方向逐条核对。
洋葱架构、六边形架构和 Clean Architecture 的图形不同,核心约束相近:业务政策位于内部,源代码依赖只能指向更稳定、更内层的政策。
这条依赖规则约束的是编译期认识关系,不是运行时调用方向。领域层可以在运行时触发数据库保存,但它通过自己拥有的端口完成,因此源代码仍不依赖数据库实现。
四类代码,不是四个必选文件夹
Clean Architecture 常见的同心结构可以理解为:
- Enterprise Business Rules:跨用例稳定的实体和业务规则;
- Application Business Rules:具体用例的编排;
- Interface Adapters:Controller、Presenter、Gateway 和映射;
- Frameworks & Drivers:Web 框架、数据库驱动、消息中间件。
层数可以调整。重点是外层细节不能成为内层政策的前提。
frameworks → adapters → application → domain
外部细节 核心政策穿过边界的是稳定数据
不要让内层接收 HttpServletRequest、JPA Entity、Kafka ConsumerRecord 或供应商 SDK 对象。边界数据应是内层可以拥有的普通结构:
public record SettleMatchCommand(MatchId matchId, PlayerId winnerId) {}
public sealed interface SettleMatchResult {
record Settled(ScoreDelta delta) implements SettleMatchResult {}
record AlreadySettled(Instant settledAt) implements SettleMatchResult {}
}这让协议升级、表结构变化和 SDK 替换被限制在适配器中。边界两侧都需要映射并不表示设计失败,而是耦合成本被显式支付。
输出边界何时有价值
简单用例直接返回结果对象即可。只有当用例需要主动控制多阶段呈现,或同一输出存在多种呈现策略时,才考虑输出端口:
interface SettleMatchOutput {
void settled(ScoreDelta delta);
void alreadySettled(Instant settledAt);
void rejected(Rejection reason);
}不要为了模仿架构图给每个返回值增加 Presenter 接口。抽象应服务于真实变化轴。
用构建边界强制规则
目录约定靠自觉,模块依赖由构建系统约束更可靠:
domain 不依赖其他业务模块
application 依赖 domain
adapters:web 依赖 application
adapters:persistence 依赖 application、domain
bootstrap 依赖全部模块并负责装配还可以用架构测试阻止回退:
@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 和数据库,核心规则能否运行?
- 替换持久化实现时,领域与用例是否保持不变?
- 外部错误是否在适配器边界被翻译为核心可理解的失败?
- 模块依赖和架构测试能否阻止内层导入外层?
- 团队是否能指出为灵活性支付的映射与抽象成本?
下一章从单个应用内部的依赖边界,推进到部署边界:先构造模块化单体,再判断是否值得把模块提取为独立服务。
参考资料
- Robert C. Martin, The Clean Architecture
- Jeffrey Palermo, The Onion Architecture
- ArchUnit, User Guide