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 高(随技术栈)

三层判断法 (拿不准就自问):

  1. “换掉数据库,这段代码要不要改?” 要改 → 不在 ①② 层。
  2. “这段逻辑能脱离 HTTP 被定时任务复用吗?” 能 → 在 ② 层。
  3. “这是钱的算法还是页面的格式?” 算法 → ① 层;格式 → ③ 层。

能不能跨层:“只能由外向内” 到底限定了什么

先说结论:依赖规则只约束方向,不约束距离。③ 可以直接 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
}

⚠️ “实体不跨界” 不是洁癖:实体带 gorm tag 之后,改一个列名就要动业务核心;实体带 json tag 之后,前端要什么字段,后端核心就长成什么样。

可运行的 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 入口就会写出第二套编排,两处规则必然漂移。两者的判定口径不同:依赖规则只回答 “编译能不能过”,用例层回答 “一次业务操作要经过哪几步”。