26.1 分层架构

一句话

分层是把变化原因不同的代码按抽象级别切开,并规定依赖只朝一个方向;它买到的不是"目录整齐",而是一个变化只影响一层——代价是每跨一层就要付一次接口与数据转换的税。

先看这章在哪:六个小节的接力关系

小节 回答的问题 与本节的关系
26.1 分层架构(本节) 为什么要切层、切在哪、经典四层是什么 给出"层"这个基本单位,同时暴露它的缺陷
26.2 依赖倒置 分层之后依赖方向反了怎么办 修 26.1 留下的"应用层依赖 DAO"
26.3 整洁架构 依赖全部朝内之后,系统长什么样 是 26.2 的完整形态
26.4 洋葱架构 领域模型当圆心时怎么分层 同构,术语与接口位置不同
26.5 六边形架构 入口和出口很多时怎么切 同构,强调端口对称
26.6 模块化与包设计 这些规则怎么落到包一级 把 26.1–26.5 变成可检查的边界

🔑 一句话串起来:分层给出结构,依赖倒置修正方向,整洁/洋葱/六边形是同一答案的三种画法,模块化把它下沉到包。

分层的本质:切的是"变化",不是"文件"

一个系统里,不同代码的变化频率往往差两个数量级。下表是常见量级,用来做相对比较,不是统计结论:

代码 变化驱动 频率 一年改几次
折扣怎么算、订单什么状态能取消 业务规则 / 监管 低 1–5 次
一次下单要经过哪几步、事务在哪开 流程调整 中 5–20 次
参数怎么校验、错误码怎么映射 接口约定 / 前端 中高 20–50 次
用 MySQL 还是 PG、用 Gin 还是 Echo 技术选型 / 升级 高 跟随社区

分层的做法就是:把变化频率相近、抽象级别相同的代码放进同一层,再规定"外层可以依赖内层,内层不许依赖外层"。

🔑 层的边界应该画在变化频率断裂处,而不是画在"人有几个"或"文件夹好不好看"处。两个模块永远一起改,就不该分成两层。

经典四层(以及三层缺了什么)

层 职责 依赖谁 典型内容 允许出现的名字
表现层 Presentation 协议转换、格式校验、入口鉴权 应用层 Controller、Handler、CLI、MQ Consumer HTTP、JSON、gRPC
应用层 Application 用例编排、事务边界、调用顺序 领域层 Service、UseCase、Command 事务、幂等键
领域层 Domain 业务规则、不变量、状态机 无(只标准库) Entity、Value Object、Domain Service、Repo 接口 业务术语,不允许出现技术名词
基础设施层 Infrastructure 具体技术实现 全部内层(实现它们的接口) DAO、Repository 实现、MQ、第三方客户端 gorm、redis、SDK

三层(Controller → Service → DAO)等于四层砍掉领域层,结果很稳定:业务规则会全部沉到 Service 里,Service 同时承担编排和规则,两个变化原因粘在一层——这就是后面所有麻烦的源头(见 26.3 的自检 Q1)。

严格分层 vs 松散分层

风格 规则 代码量 适合 典型问题
严格分层 (closed) 第 N 层只能调第 N−1 层,不能跨层 大:每层一次 DTO 转换 团队 ≥ 8 人、层职责稳定 透传层(sinkhole)
松散分层 (open) 允许跨层调用(表现层可直达领域层) 小 小团队、层数 ≤ 3 层职责被绕过,规则散落
分层桥 (bridge) 严格 + 白名单指定可跨越的边 中 只有少数场景需要直连 白名单会越加越长

POSA 的定义里还有一条常被忽略的约束:上层只能通过接口使用下层,下层的内部实现对上层不可见。翻译成人话:上层不该知道下层内部怎么实现,也不该越过中间层直接操作更下层——除非你显式开了桥。

⚠️ 严格分层最常见的死法是 sinkhole:一条请求穿过 5 层,其中 4 层只是 return next.X(req)。这时"分层"没有隔离任何变化,只增加了每次改动的文件数。

代码:一个能跑的分层

一个下单流程,四层齐备。注意领域层只是一个纯函数——领域层不必是"一堆类",它只要求不依赖任何外部技术。

package main

import (
	"errors"
	"fmt"
)

// ========== ① 基础设施层:只有它认识存储 ==========
type orderRow struct {
	ID      string
	UserID  string
	Amount  int64 // 原价,单位分
	Payable int64 // 应付,单位分
	Status  string
}

type orderDAO struct {
	rows map[string]*orderRow
}

func newOrderDAO() *orderDAO { return &orderDAO{rows: map[string]*orderRow{}} }

func (d *orderDAO) Insert(r *orderRow) error {
	if _, ok := d.rows[r.ID]; ok {
		return fmt.Errorf("订单 %s 已存在", r.ID)
	}
	d.rows[r.ID] = r
	return nil
}

func (d *orderDAO) FindByID(id string) (*orderRow, error) {
	r, ok := d.rows[id]
	if !ok {
		return nil, fmt.Errorf("订单 %s 不存在", id)
	}
	return r, nil
}

func (d *orderDAO) List() []*orderRow {
	out := make([]*orderRow, 0, len(d.rows))
	for _, r := range d.rows {
		out = append(out, r)
	}
	return out
}

// ========== ② 领域层:纯业务规则,不碰存储、不碰 HTTP ==========
// 会员等级 3 打 85 折,2 打 92 折,其余原价
func payable(amount int64, level int) int64 {
	switch level {
	case 3:
		return amount * 85 / 100
	case 2:
		return amount * 92 / 100
	default:
		return amount
	}
}

// ========== ③ 应用层:编排一次用例 ==========
type orderApp struct {
	dao *orderDAO // ⚠️ 具体类型:应用层认识基础设施层
	seq int
}

func newOrderApp(dao *orderDAO) *orderApp { return &orderApp{dao: dao} }

func (a *orderApp) Create(userID string, amount int64, level int) (*orderRow, error) {
	if amount <= 0 {
		return nil, errors.New("金额必须大于 0")
	}
	a.seq++
	r := &orderRow{
		ID:      fmt.Sprintf("O%d", a.seq),
		UserID:  userID,
		Amount:  amount,
		Payable: payable(amount, level), // 规则来自领域层
		Status:  "CREATED",
	}
	if err := a.dao.Insert(r); err != nil {
		return nil, err
	}
	return r, nil
}

func (a *orderApp) List() []*orderRow { return a.dao.List() }

// ========== ④ 表现层:只做协议转换 ==========
type createReq struct {
	UserID string
	Amount int64
	Level  int
}

func handleCreate(app *orderApp, req createReq) (string, error) {
	r, err := app.Create(req.UserID, req.Amount, req.Level)
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("%s 应付 %d 分(原价 %d 分)", r.ID, r.Payable, r.Amount), nil
}

func main() {
	dao := newOrderDAO()
	app := newOrderApp(dao)

	for _, req := range []createReq{
		{UserID: "U1", Amount: 10000, Level: 3},
		{UserID: "U1", Amount: 19900, Level: 2},
		{UserID: "U2", Amount: 500, Level: 1},
		{UserID: "U2", Amount: 0, Level: 1},
	} {
		out, err := handleCreate(app, req)
		if err != nil {
			fmt.Printf("创建失败:%v\n", err)
			continue
		}
		fmt.Printf("创建成功:%s\n", out)
	}

	fmt.Printf("当前共 %d 单\n", len(app.List()))
}

输出:

创建成功:O1 应付 8500 分(原价 10000 分)
创建成功:O2 应付 18308 分(原价 19900 分)
创建成功:O3 应付 500 分(原价 500 分)
创建失败:金额必须大于 0
当前共 3 单

💡 单文件演示的是"分层心智",真实项目每层是一个包,依赖方向由 import 图决定——这正是 26.6 要检查的东西。

这段代码能上线,但有四个结构性问题

# 问题 位置 后果
1 应用层直接依赖具体 *orderDAO orderApp.dao 换 Redis、加缓存、加只读从库都要改应用层;单测必须构造 DAO
2 四层共用 orderRow ①②③④ 加一个 DB 列,四层同时被编译、被审查
3 表现层返回拼好的字符串 handleCreate 加一个 gRPC 入口,格式化逻辑要抄一遍
4 领域规则是没有归属的裸函数 payable 规则一多就散成工具函数,谁都能调用、谁都能改

第 1 和第 2 条是同一个病:依赖方向没错(外层 → 内层),但依赖的"东西"太具体。修法不是再加一层,而是 26.2 依赖倒置:应用层定义接口,基础设施层实现,组装点注入。

🔑 分层的验收标准只有两条:改一个技术细节,是否只动一层?改一条业务规则,是否只动一处? 做不到,层就只是文件夹。

分层失效的三种典型形态

形态 症状 根因 修法
文件夹分层 有 controller/ service/ dao/ 目录,但 service 里 import "gorm.io/gorm"、直接返回带 tag 的 model 按"代码类型"切目录,没按依赖切 26.2 + 26.3,把端口抽出来
透传层 (sinkhole) 过半方法只有一行 return next.X(...) 严格分层 + 层数超过变化频率的粒度 合并相邻两层,或改松散分层 + 桥
反向依赖 领域层 import 了 dao 或 models,业务逻辑里出现 db.Query 经典三层默认"上层调下层",Service 顺手就用了 DAO 只能靠依赖倒置:内层定接口,外层实现

还有一种更隐蔽的:层内上帝类。层切对了,但 UserService 有 2000 行、40 个方法,等于把所有变化又塞回一个文件——层的隔离在这里失效,该拆的是"服务职责"(见 40.2 耦合与内聚度量)。

分几层:用变化频率算,不要用"未来可能需要"算

你的场景 建议层数 理由
脚本、内部工具、一次性数据修复 1–2 没有第二层可替换,分层只有成本
纯 CRUD 后台(表单直映射表) 3 没有业务规则,领域层会是空的
有真实规则(计价、状态机、风控、账务) 4 规则要能脱离 DB/HTTP 单测
多入口 / 多端复用同一套逻辑 4 + 端口 应用层成为复用点(26.3 整洁架构、26.5 六边形架构)
团队 ≥ 8 人、多人并行改同一系统 4 + 边界检查 需要机器守边界(26.6 模块化与包设计)

判断"这一层该不该存在"只问一句:它有没有独立的变化原因? 没有就是透传层;有,就留着。

分层 ≠ 模块化 ≠ 微服务

三个维度经常被混为一谈。它们是正交的:

维度 切法 边界强度 跨边界成本 解决什么
分层 水平:按技术职责 编译期(同进程) 一次函数调用 变化隔离
模块化 垂直:按业务 / 变更原因 编译期(包) 一次函数调用 + 依赖规则 团队并行、可独立演进
微服务 垂直:按业务 进程 / 网络 RPC + 数据一致性 + 运维 独立伸缩、故障隔离、独立发布(30 章)

🔑 分层和模块化是同一进程里的两种切法,互相垂直:一个电商系统可以既分四层、又分 order / payment / inventory 三个模块。别拿分层当微服务的替代(它不解决部署边界),也别拿微服务逃避分层(进程内还是要有结构)。

反模式

反模式 后果
按"未来可能要换数据库"提前抽全部接口 接口爆炸,读代码要跳 3 个文件才看到实现
严格分层下每层都做一次 DTO 转换,字段一一对应 加一个字段改 4 个 struct,收益为零
让领域层 import ORM 或框架(哪怕只是为了 tag) 业务核心与技术同生共死,换库要重写核心
表现层直接调 DAO 绕过应用层,事务/幂等/权限全部丢失
把所有"不知道放哪"的代码塞进 service Service 变上帝类,层隔离失效
用"我们分了 7 层"当架构水平证明 层数是结果不是目标;问题永远是变化有没有被隔离
分层只做一半:目录分了,依赖没管 最坏情况——看起来有架构,出事时发现全是循环依赖

自检

Q1 一个项目有 controller / service / dao 三个目录,service 里 import "gorm.io/gorm" 并直接返回带 gorm:"column:..." tag 的 struct。它算分层架构吗?问题出在哪一层?

▸ 答案:目录上是三层,架构上不是。分层成立的条件是"内层不依赖外层的具体技术",这里 Service 直接依赖 ORM 类型,且业务模型被数据库列结构绑架——service 实际是基础设施层的延伸。问题不在"少了一层领域层",而在依赖方向与数据形状:即使补上领域层,只要 service 还 import gorm,补的那层也只是个过道。修法是应用层定义端口、dao 实现它(26.2)。

Q2 严格分层下,你发现 OrderService.Create 只有一行 return s.repo.Insert(...),OrderController.Create 也只有一行 return svc.Create(...)。该删哪一层?依据是什么?

▸ 答案:先看变化原因,不看行数。如果校验/编排将来会增长(比如要查库存、锁优惠券、发事件),保留应用层,它是有内容的位置;如果这一层只是转发且看不到独立变化,就把 Controller 直接连到领域/仓储,删掉中间层。判断标准不是"代码少",而是"这一层有没有自己的变化驱动"——没有就是 sinkhole。

Q3 有人主张:"既然最后要拆微服务,现在就别分层了,直接按服务拆包。"这句话的问题在哪?

▸ 答案:混淆了两个正交维度。分层解决进程内的变化隔离,微服务解决部署与团队边界;拆成服务后,每个服务内部仍然要分层(否则规则、协议、存储又粘在一起,只是把大泥球搬到了多个进程)。而且服务边界的前提是业务边界清楚——业务边界恰恰要靠领域分层来梳理(29 章)。正确顺序是:先分层把规则提纯,再按业务边界拆包,最后才考虑拆服务。

Q4 四层里,为什么"领域层只依赖标准库"比"必须分成四层"更重要?

▸ 答案:层数是画法,依赖是约束。领域层不依赖任何技术的直接收益是可以脱离 MySQL / HTTP / 框架编译和单测——1000 条规则用例在毫秒级跑完,而带 DB 的集成测试是秒级且易 flaky。层数可以按项目调整(3 层、5 层都行),但只要领域层沾上框架,这个收益立刻归零,且几乎不可逆(越晚拆越贵)。