架构设计:从「什么最容易变」推导分层
不要套模板。 四层(api/service/domain/db)只是这套原则在 Web 后端的一个投影。 换场景层名和数量都变,不变的只有依赖方向。
何时用
- 用户要给新项目定架构("这个怎么分层")
- 丢进来一坨能跑但难维护的代码,要重构(最高频)
- 评审现有项目:依赖方向错在哪
- 用户问"Agent 系统 / 数据管线 / CLI 该怎么分层"
一、三条铁律(不可商量)
全部来自 Robert C. Martin 的组件耦合原则,与语言、框架无关。
① SDP — 依赖指向稳定
易变的必须依赖不易变的,不能反过来。
② SAP — 稳定者抽象
被很多人依赖的组件,必须是抽象的(接口/协议),否则它一改就全崩 —— 这叫「痛苦区」。 反过来,不稳定的组件应该是具体实现。
SDP + SAP 合起来 = 依赖倒置架构。
③ ADP — 依赖图无环
出现环就必须打破。打破环的标准手法是引入抽象(把环里某个依赖反转掉), 不是"加个中间层"也不是"把公共部分抽出来"。
二、核心操作只有两步
(此二步出自 Oliver Zihler,Alistair Cockburn 本人引用为标准路径)
Step 1|反转依赖
把「高层调用低层」改成「低层实现高层定义的接口」。
Step 2|确保端口定义在内部
接口的定义必须住在被依赖的稳定侧,不是实现侧。
⭐ 判据:什么叫「做对了」
Cockburn 给了强弱两档,要往强里做:
| 档位 | 表现 |
|---|---|
| 弱 | 知道要用 SQL,虽然不绑具体库,但接口仍用 SQL 词汇表达 |
| 强 ⭐ | 内部完全不知道外部技术存在 —— 连「外面有个数据库」都不知道 |
自检问句:把接口拿给一个不懂这个技术栈的人看,他能看懂吗? 看不懂 = 技术词汇漏进去了 = 弱实现。
三、怎么推导分层(不套模板的做法)
第 1 步:列「变化频率」
拿一张纸,把项目里所有东西按多久变一次排序:
变最快 ────────────────────────────► 最稳定
UI框架 外部API 数据库 ←→ 业务规则 领域概念
判断依据:
- 业务 vs 技术:业务规则("名额满了不能报名")十年不变;技术选型(FastAPI→Flask)常变
- 这条规则是公司特有还是行业通用? 公司特有 → 易变;行业通用 → 稳定
第 2 步:按「变化频率」切层
同一变化频率的东西放一层。 这就是 CCP(共同闭包)—— 一起变的放一起。
验证方法:如果改一个需求,你发现要同时改 A 和 B 两个文件,那 A 和 B 可能该在同一层。 如果改 A 从不需要改 B,那它们该在不同层。
第 3 步:画依赖箭头,全部指向稳定侧
所有箭头必须是单向的、指向更稳定的一侧。
第 4 步:找出唯一装配点
Cockburn 叫它 Configurator,也有地方叫 Composition Root。
全项目只有这一处 new 对象。 它知道所有实现,把实现注入给抽象。
测试时换掉它 = 换掉整个运行环境。
四、量化体检(对现有项目用)
三个指标,能手算也能脚本化:
I(不稳定度)= 出度 / (入度 + 出度)
入度 = 多少人依赖我;出度 = 我依赖多少人
I=0 最稳定(众人依赖我,我不依赖人);I=1 最不稳定
A(抽象度)= 抽象类/接口数 / 总类数
D(距主序列距离)= |A + I - 1|
| D 值 | 含义 | 处方 |
|---|---|---|
| ≈ 0 | 健康 | 保持 |
| > 0.3 | 无用处区 —— 太抽象,没人用 | 删掉或合并抽象 |
| > 0.5 | 痛苦区 —— 太具体,却被大量依赖 | 立刻抽接口 |
用法:用户说"我这架构有问题但说不清",算一遍 D,痛苦区的那个组件就是病根。
五、跨场景映射(层名自己推,别抄)
以下是同一套原则在不同场景的投影,仅供对照,不要直接套:
| 场景 | 稳定核心 | 易变外壳 | 端口(稳定侧定义的接口) |
|---|---|---|---|
| Web 后端 | domain 业务规则 | api / db | Repository 接口、UseCase 接口 |
| CLI 工具 | core 核心逻辑 | cli 参数解析 / io | 输入输出抽象 |
| Agent 系统 | agent-loop 决策逻辑 | harness / tools / model client | Tool 接口、Model 接口 |
| 数据管线 | transform 变换规则 | source / sink | Reader / Writer 接口 |
| 前端 | state 状态机 | view / api-client | API 客户端接口 |
| ML 训练 | model 定义 / 损失 | data loader / trainer / 日志 | Dataset 接口、Logger 接口 |
| 游戏 | game-logic 规则 | render / input / assets | InputProvider 接口 |
| 嵌入式 | app-logic | drivers / hal | HAL 接口(James Grenning 叫它 Service Abstraction Layer) |
⭐ Agent 场景特别注意(用户近期主攻方向): 判据 === 「谁是决策逻辑,谁是对接环境」。agent-loop 是稳定的(怎么想), harness/tools 是易变的(对接什么环境)。所以应当是 harness 依赖 agent-loop 定义的接口, 不是 agent-loop import 具体工具。
六、输出物(交付什么)
重构/设计任务收尾,给这四样:
- 职责表:每层干什么、什么情况归它、边界在哪
- 依赖图:箭头方向(文字版 mermaid 即可)
- 接口归属清单:每个抽象接口归哪层、由谁实现
- 唯一装配点:哪条命令/哪个 main 里 new 对象
代码骨架按用户的技术栈给(Python/FastAPI 是高频栈)。
七、反模式(遇到就报)
| 症状 | 诊断 | 处方 |
|---|---|---|
| 一个函数里既有 SQL 又有 if 业务判断又有 HTTP 异常 | 三层糊在一起 | 拆 domain / service / db / api |
domain 里 import 了 ORM 或 web 框架 |
依赖方向反了 | 反转:domain 定义接口,外部实现 |
| 接口定义在实现侧(db 定义 IRepository) | 端口位置错了 | 把接口搬到 service/domain |
| 依赖图有环 | 违反 ADP | 用抽象打断环 |
| 组件被 20 处依赖但全是具体类 | 在痛苦区 | 抽接口 |
到处 new Database() |
缺装配点 | 收敛到 Composition Root |
| 为了"解耦"抽了 20 个没用过的接口 | 在无用处区 | 删 |
八、⚠️ 别过度设计
原教程自己承认:
用例本身很小,重构后代码更多、文件更多,看起来反而更复杂;在真实项目中值得做。
判据:
- 项目活不过三个月 / 只有你一个人用 → 别拆
- 要被面试官看 / 要被别人接手 / 技术栈会换 → 拆
拆到「为了解耦而解耦」就是纯亏损。宁可写具体的重复代码,也别抽用不上的抽象。
参考
references/theory-sources.md— 六条组件原则原文要点、I/A/D 公式出处、 Hexagonal 与 Clean/Onion 的关系、从分层到端口的官方两步法