26.3 整洁架构
一句话
整洁架构用一条规则把系统切成同心圆:源代码的依赖只能由外向内 (可以跨层,不能反向);数据库、Web 框架、消息队列退化成可替换的外挂细节,核心业务规则不 import 它们中的任何一个。
💡 本节是 26.1–26.6 这条线的中点:26.1 分层架构 给出"层",26.2 依赖倒置 修正方向,本节给出完整形态;26.4、26.5 是同构的另两种画法,26.6 把规则下沉到包一级。
只有一条规则:依赖规则 (The Dependency Rule)
┌──────────────────────────────────────────┐
│ ④ Frameworks & Drivers(框架与驱动) │ HTTP、DB、MQ、CLI
│ ┌────────────────────────────────────┐ │
│ │ ③ Interface Adapters(接口适配器) │ │ Controller、Gateway、Presenter
│ │ ┌──────────────────────────────┐ │ │
│ │ │ ② Use Cases(应用业务规则) │ │ │ 编排:找实体、调规则、存结果
│ │ │ ┌────────────────────────┐ │ │ │
│ │ │ │ ① Entities(企业规则) │ │ │ │ 最稳定:钱怎么算、单怎么关
│ │ │ └────────────────────────┘ │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
依赖箭头 → 只能指向内层;越内层越稳定、越不该改
🔑 依赖规则只管一件事:内层的代码不允许出现外层的名字。
import "gorm.io/gorm"出现在 entity 里,这一刻整洁架构就已经破了,后面所有分层都只是文件夹摆放。
四层各放什么
| 层 | 放什么 | 允许依赖 | 典型内容 | 变更频率 |
|---|---|---|---|---|
| ① Entities | 企业级业务规则,跨应用通用 | 只有标准库 | 金额校验、状态机、复式记账规则 | 极低(年级) |
| ② Use Cases | 应用级业务规则:一次用例的编排 | ① + 自己定义的端口接口 | 下单、支付、退款流程 | 低(月级) |
| ③ Interface Adapters | 内外数据格式转换 | ①② | Controller、Repository 实现、DTO 转换 | 中(周级) |
| ④ Frameworks & Drivers | 具体技术 | 全部 | Gin、gorm、Redis 客户端、Kafka | 高(随技术栈) |
三层判断法 (拿不准就自问):
- “换掉数据库,这段代码要不要改?” 要改 → 不在 ①② 层。
- “这段逻辑能脱离 HTTP 被定时任务复用吗?” 能 → 在 ② 层。
- “这是钱的算法还是页面的格式?” 算法 → ① 层;格式 → ③ 层。
能不能跨层:“只能由外向内” 到底限定了什么
先说结论:依赖规则只约束方向,不约束距离。③ 可以直接 import ①,④ 也可以直接 import ①,跳过中间层完全合法;被禁止的只有反向。但有三件事必须分开答——能不能 import (方向)、该不该这么调 (职责)、核心有没有被改形 (拥有权)。
上一节 “允许依赖” 那一列其实已经给出半个答案:③ 写 ①②,④ 写 “全部”。那列列的是集合,不是相邻层。把读者最常追问的五个问题逐个定死:
| 追问 | 答案 | 依据 |
|---|---|---|
| ① 能被 ③ 引用、跳过 ② 吗? | 能 | ③ 的 Repository 实现必须产出 ① 的 Order;规则从没写 “必须相邻” |
| ① 能被 ④ 引用吗? | 能 | 组装点 main() / wire 要 new 实体、new 用例、new 适配器,它引用所有内层 |
外层直接使用核心(controller 直接调 order.Pay())违规吗? | 依赖不违规,职责越界 | 事务、幂等、权限、事件原本挂在用例上,绕过去就整条丢失 |
| 内层能调用外层吗? | 能 “调用”,不能 “提及” | 内层调自己定义的端口,实现由外层注入:控制流向外,依赖向内 |
| 跨层引用没有隐性代价吗? | 有,要算传递闭包 | ② 只要 import 了 ③ 的工具包,① import ② 就把 ① 拖到 ③ 上 |
四个方向的判定,一张图看完:
依赖 (import) 合法: ④ ──▶ ③ ──▶ ② ──▶ ①
跨层依然合法: ④ ──▶ ① ③ ──▶ ①
反向一律违规: ① ──▶ ③ ② ──▶ ④
控制流 (调用) 合法: ③ ──▶ ② ──▶ 端口 ⇢ ③ 的实现(最后一跳控制流向外,依赖不倒转)
// ✅ ③ 跨层引用 ①(跳过 ②):Repository 的实现必须产出内层认识的类型
type gormOrderRepo struct{ db *gorm.DB }
func (r *gormOrderRepo) Find(ctx context.Context, id string) (*Order, error) {
var row orderRow // ③ 自己的持久化模型,gorm tag 只出现在这里
if err := r.db.WithContext(ctx).First(&row, "id = ?", id).Error; err != nil {
return nil, err
}
return &Order{ID: row.ID, Amount: row.Amount, Status: row.Status}, nil // 边界处转换
}
// ✅ ④ 跨层引用全部内层:组装点的职责就是把它们接起来
func main() {
repo := &gormOrderRepo{db: openDB()}
uc := NewPayOrder(repo, systemClock{}) // ②
http.Handle("/pay", NewPayHandler(uc)) // ③
_ = http.ListenAndServe(":8080", nil)
}
// ❌ 反向:② 的入参里出现 ③ 的持久化模型,② 从此认识 gorm 的列结构
func (uc *PayOrder) Handle(ctx context.Context, row orderRow) error
⚠️ 跨层合法的前提是数据必须是内层的形状:③ 拿 ① 的
Order对,② 收 ③ 的orderRow错。方向对、数据反了,等于没做。
💡 “Controller 收
*Order该不该禁” 是另一件事:它不破坏依赖方向,破坏的是 “外部能直接改业务状态” 和 “实体被序列化格式绑架”。依赖规则管名字从哪来,跨界数据管谁持有谁,两条别混成一条 (见下方 “跨界数据” 一节)。
⚠️ 更隐蔽的一种翻车:为了 ③ 少写映射,给 ① 的
Order挂gorm:"column:...";为了省一个出参 DTO,再挂json:"..."。这两笔一加,① 反过来依赖外层的格式,依赖在 “省事” 里掉了头。验收只有一句:把 gorm 和 JSON 全删掉,① 还能编译吗?
🔑 能 import 看方向 (可跨层、不可反向);该不该调看职责 (用例编排不能被绕过);有没有变形看拥有权 (核心只认自己的形状)。
💡 圈数不是契约。原文:“The circles are schematic… There's no rule that says you must always have just these four”。只有 policy / detail 两圈的系统照样成立,四圈是默认画法,不是验收标准。同理,① 被多个应用、多个入口共用是设计目标——被大量引用不是问题,被外部格式改写才是。
与洋葱、六边形的关系:三种画法,一个东西
| 维度 | 整洁架构(Clean) | 洋葱架构(Onion) | 六边形(Hexagonal / Ports & Adapters) |
|---|---|---|---|
| 提出者 | Robert C. Martin, 2012 | Jeffrey Palermo, 2008 | Alistair Cockburn, 2005 |
| 图形 | 四层同心圆 | 同心圆(Domain Model 为核心) | 核心 + 左右两侧端口 |
| 核心概念 | 依赖规则 | 依赖向内、Domain 无依赖 | 端口与适配器,内外对称 |
| 与外部世界 | 由外向内单向调用 | 由外向内单向调用 | 输入端口 / 输出端口,两侧都靠适配器 |
| 强调点 | 依赖方向 + 用例 | 领域模型在正中心 | 主动方与被动方一视同仁 |
| 实际差别 | 几乎为零:都要求「领域不依赖技术」,都靠依赖倒置实现 | 同左 | 同左 |
💡 三者的区别是画法和术语,不是设计。面试被问区别,正确答案是 “它们回答同一个问题:如何让业务核心不依赖技术细节;六边形多给了一个对称视角——外部主动调用你 (HTTP) 和你主动调外部 (DB) 都是适配器”。
依赖倒置是唯一的实现手段
内层需要 “存订单”,但不能 import 数据库。做法是内层定义接口,外层实现:
// ②层定义(内层)——注意:接口属于使用方,不属于实现方
type OrderRepo interface {
Find(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, o *Order) error
}
// ④层实现(外层)——gorm、Redis、HTTP 客户端都藏在这里
type gormOrderRepo struct{ db *gorm.DB }
判断接口该放在哪一层,只问一句:**谁调用它?**调用方定义接口,实现方遵守接口。反过来 (实现方定义接口、调用方依赖具体类型) 就是依赖倒置没做,只是把耦合换了个位置。
跨界数据:不要让实体穿过边界
| 做法 | 问题 |
|---|---|
Controller 直接收 *Order 实体 | 外部字段能直接改业务状态;实体被序列化格式绑架 |
Use Case 直接返回带 json:"..." tag 的实体 | ①层依赖序列化细节,等于依赖框架 |
| 每层用自己的模型,边界处转换 | 多写几个 struct,但每层能独立演进 |
// ③层的入参模型(可带校验 tag)
type PayRequest struct {
OrderID string `json:"order_id" binding:"required"`
}
// ②层的命令对象(不带任何传输格式知识)
type PayCommand struct{ OrderID string }
// ②层的出参(不是实体本身)
type PayResult struct {
OrderID string
Status string
PaidAt time.Time
}
⚠️ “实体不跨界” 不是洁癖:实体带
gormtag 之后,改一个列名就要动业务核心;实体带jsontag 之后,前端要什么字段,后端核心就长成什么样。
可运行的 Go 实现
一个支付用例,实体不认识数据库、不认识时钟、不认识 HTTP (go run main.go 可直接跑):
package main
import (
"context"
"errors"
"fmt"
"time"
)
// ========== ① Entities:企业级业务规则,只依赖标准库 ==========
type Order struct {
ID string
Amount int64 // 单位:分
Status string // CREATED / PAID
}
var (
ErrInvalidAmount = errors.New("金额必须大于 0")
ErrAlreadyPaid = errors.New("订单已支付,不能重复支付")
)
// Pay 是纯业务规则:不查库、不取时间、不发消息。
func (o *Order) Pay() error {
if o.Amount <= 0 {
return ErrInvalidAmount
}
if o.Status == "PAID" {
return ErrAlreadyPaid
}
o.Status = "PAID"
return nil
}
// ========== 端口:由内层定义,外层实现(依赖倒置的落点) ==========
type OrderRepo interface {
Find(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, o *Order) error
}
type Clock interface {
Now() time.Time
}
// ========== ② Use Cases:应用业务规则 ==========
type PayCommand struct{ OrderID string }
type PayResult struct {
OrderID string
Status string
PaidAt time.Time
}
type PayOrder struct {
repo OrderRepo // 只依赖接口,不知道背后是 MySQL 还是内存
clock Clock // 时间也是外部依赖,否则测试没法固定时间
}
func NewPayOrder(repo OrderRepo, clock Clock) *PayOrder {
return &PayOrder{repo: repo, clock: clock}
}
func (uc *PayOrder) Handle(ctx context.Context, cmd PayCommand) (PayResult, error) {
o, err := uc.repo.Find(ctx, cmd.OrderID)
if err != nil {
return PayResult{}, err
}
if err := o.Pay(); err != nil { // 业务规则在实体里
return PayResult{}, err
}
if err := uc.repo.Save(ctx, o); err != nil {
return PayResult{}, err
}
// 返回的是出参模型,不是实体本身
return PayResult{OrderID: o.ID, Status: o.Status, PaidAt: uc.clock.Now()}, nil
}
// ========== ③④ 适配器与驱动:可替换的细节 ==========
type memoryRepo struct {
data map[string]*Order
}
func (r *memoryRepo) Find(_ context.Context, id string) (*Order, error) {
o, ok := r.data[id]
if !ok {
return nil, fmt.Errorf("订单 %s 不存在", id) // 错误信息带上业务上下文,便于排查
}
return o, nil
}
func (r *memoryRepo) Save(_ context.Context, o *Order) error {
r.data[o.ID] = o
return nil
}
type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }
func main() {
repo := &memoryRepo{data: map[string]*Order{
"A1001": {ID: "A1001", Amount: 9900, Status: "CREATED"},
}}
// 换掉 repo 或 clock,用例代码一行不用改:这就是依赖倒置的收益
uc := NewPayOrder(repo, fixedClock{t: time.Date(2026, 10, 24, 15, 0, 0, 0, time.UTC)})
got, err := uc.Handle(context.Background(), PayCommand{OrderID: "A1001"})
fmt.Printf("① 首次支付:%+v err=%v\n", got, err)
_, err = uc.Handle(context.Background(), PayCommand{OrderID: "A1001"})
fmt.Printf("② 重复支付:err=%v\n", err)
_, err = uc.Handle(context.Background(), PayCommand{OrderID: "B404"})
fmt.Printf("③ 订单不存在:err=%v\n", err)
}
输出:
① 首次支付:{OrderID:A1001 Status:PAID PaidAt:2026-10-24 15:00:00 +0000 UTC} err=<nil>
② 重复支付:err=订单已支付,不能重复支付
③ 订单不存在:err=订单 B404 不存在
三段输出连起来看:① 成功一次、② 业务规则第二次拦住、③ 走不到业务规则就被端口返回错误。三条路径全部不碰数据库——这就是依赖倒置换来的东西。
为什么这套结构能测,且快
| 结构 | 测一次 PayOrder.Handle 需要 | 耗时 |
|---|---|---|
| 无分层(逻辑写在 handler 里,直连 DB) | 起 MySQL + 起 HTTP 服务 + 造数据 | 秒级 |
| 整洁架构 | memoryRepo + fixedClock,纯内存 | 微秒级 |
1000 个用例的回归测试,前者是 “40 分钟 + 天天 flaky”,后者是 “1 秒跑完”。可测试性不是测试团队的事,是依赖方向决定的 (见 40.1 可测试性)。
代价:这套结构不是免费的
| 代价 | 量级 | 什么时候值得付 |
|---|---|---|
| 文件数上升 | 一个用例 ≈ 实体 + 端口 + 用例 + 适配器 + DTO,3–6 个文件 | 业务规则复杂、生命周期 > 2 年 |
| 映射代码 | 每层一次 struct 转换,10–30 行样板 | 需要多端(App / 开放 API)复用同一用例 |
| 心智成本 | 新人要理解「为什么改个字段要动 4 个文件」 | 团队 ≥ 3 人、有交接需求 |
| 事务边界变模糊 | 用例跨多个 Repo 时,事务该放哪层要说清 | 见下方 |
**事务放哪:**最干净的做法是 “一个用例 = 一个事务”,在 ③ 层用 UnitOfWork 包裹用例调用;把 tx 塞进 Use Case 会让 ② 层依赖事务 API。小系统直接用 “用例只有一个 Repo 写操作”,自然就是单事务。
决策表:你的项目要不要整洁架构
| 你的情况 | 选择 |
|---|---|
| 纯 CRUD 后台,字段直接映射表单 | 不要。三层(Controller-Service-DAO)足够,整洁架构是纯增负担 |
| 有真实业务规则(计价、状态机、风控、账务),生命周期 ≥ 2 年 | 要,至少 Entities + Use Cases 两层分开 |
| 需要多端复用同一套业务逻辑(HTTP / gRPC / 定时任务 / MQ) | 要,用例层是复用点 |
| 只有一个适配器、预计不会换数据库 | 不必抽接口,先分层,等第二个实现出现再抽(YAGNI) |
| 团队 < 3 人、产品还在找 PMF | 不要,交付速度优先 |
反模式
| 反模式 | 后果 |
|---|---|
Entity 上挂 gorm / json tag | 换 ORM 或改 API 字段名就要动业务核心,依赖规则失效 |
内层 import 框架(gin、gorm、redis) | 分层退化成文件夹分类,核心与框架同生共死 |
Use Case 里写 SQL / 直接调 db.Query | 跳过端口,依赖倒置失效,用例无法脱离数据库测试 |
| Controller 直接调 Repository,绕过 Use Case | 业务规则散落在 handler,多个入口(HTTP / MQ)行为不一致 |
| 以为 “依赖必须逐层向内,不能跳层” | 为了 “合规” 在中间层加纯转发包装,多一层样板,依赖距离没变、可读性更差 |
| 拿 “依赖方向合规” 为绕过用例的调用辩护 | 事务、幂等、权限、事件全部丢失,第二个入口必然另写一套编排,两处规则漂移 |
| 为每个类都抽一个接口(只有一个实现也抽) | 接口爆炸,阅读成本翻倍,收益为零 |
| 用例返回 Entity,让上层随便改 | 外部代码能直接改业务状态,规则形同虚设 |
| 用「领域服务」当垃圾桶,什么都往里塞 | 变成上帝类,依赖方向重新混乱 |
全局单例 db.Get() 从内层调用 | 隐式依赖,测试无法替换,等价于没有依赖倒置 |
自检
Q1 同事说 “我们用整洁架构:handler → service → dao,一共三层”。这算整洁架构吗?
▸ 答案:不算。整洁架构的判定标准不是层数,是依赖方向,且核心不依赖技术。经典三层里 service 直接 import gorm 的 *gorm.DB、直接返回带 tag 的 model,依赖是向外的 (业务依赖数据库)。分层只是 “代码分文件夹”;整洁架构要求内层定义端口、外层实现,业务核心能脱离数据库编译与测试。检验方法只有一个:能不能在只有内存实现的情况下把用例跑起来。
Q2 内层定义了 OrderRepo 接口,gorm 实现放在外层。第二天产品要加 Redis 缓存,你把缓存逻辑写进了 Use Case (先查缓存、miss 再查库)。哪里错了?怎么改?
▸ 答案:缓存是实现细节,不该进用例。正确做法有两个:① 在 OrderRepo 外面再包一层 cachedOrderRepo (装饰器),它实现同一个接口,用例代码零改动;② 如果缓存属于业务语义 (如 “读己之写”),才把 “读一致性要求” 提炼成用例的显式参数。判断标准:**这段逻辑是否因为技术选型变化而变化?**会变 → 放外层。
Q3 一个只有 3 张表、全是增删改查的内部后台,你还会上整洁架构吗?给出理由和一个折中方案。
▸ 答案:不会。CRUD 没有 “企业级业务规则”,Entities 层是空的,结果只剩接口与 DTO 的样板,交付速度下降 30% 以上而收益接近 0。折中:保持三层,但守住一条——业务校验写在一个独立的、只依赖标准库的包里 (而不是散在 service 里),这样将来出现复杂规则时能低成本抽出 Entities 层。架构要为 “可能的变化” 留缝,但不为 “想象中的复杂” 先付全款。
Q4 同事把 handler 里的 uc.Handle(ctx, cmd) 换成 order.Pay(),理由:“依赖方向还是 ③→①,没违规”。这句话对吗?错在哪?
▸ 答案:依赖方向确实没违规——③ 引用 ① 合法,跨层也合法。错在拿结构合规冒充行为合规:事务边界、幂等键、权限校验、领域事件、审计原本都挂在 Handle 上,这一改全没了;再来一个 MQ 入口就会写出第二套编排,两处规则必然漂移。两者的判定口径不同:依赖规则只回答 “编译能不能过”,用例层回答 “一次业务操作要经过哪几步”。