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 层都行),但只要领域层沾上框架,这个收益立刻归零,且几乎不可逆(越晚拆越贵)。