分层与六边形架构:依赖方向决定可维护性
分层架构之所以在大项目里失效,几乎都栽在同一件事上:依赖方向反了。领域逻辑里出现 SQL 语句、业务服务里 import 了 Redis 客户端、单元测试必须连数据库才能跑——结构还在,可维护性已经没了。六边形架构(hexagonal architecture)给出的解法只有一句:让所有依赖指向领域。
问题:依赖方向反了会发生什么
反例(依赖指向基础设施):
domain/order.go ──import──▶ gorm.DB / redis.Client / http.Client
后果:换 ORM/存储要改业务代码;领域逻辑离不开数据库,单测变慢变脆;
业务规则被分页、事务、连接池等实现细节污染;依赖成网状,改一处动多处
正例(依赖倒置,箭头全部指向 domain):
handler ──▶ app ──▶ domain ◀── infra(实现 domain/app 定义的端口)
关键点:接口定义在调用方(domain / app),实现在被调用方(infra)。这就是依赖倒置(dependency inversion)。它不是"多写一个接口",而是把编译期的依赖箭头调了个头。
两层还是四层:分层要按职责而非名词
| 层 | 职责 | 不该出现的东西 |
|---|---|---|
| 接入层(adapter in / handler) | 协议解析、鉴权、参数校验、错误码映射 | 业务规则、SQL、事务编排 |
| 应用层(application) | 用例编排、事务边界、权限判定、调用领域 | 具体 SQL、HTTP 细节、领域不变量 |
| 领域层(domain) | 实体、值对象、聚合、领域服务、领域事件 | 任何外部框架与 I/O |
| 基础设施层(adapter out / infra) | 持久化、缓存、消息、外部接口实现、第三方 SDK 封装 | 业务分支逻辑 |
分层判断口诀:换掉数据库要改哪些文件?答案只该是 infra 层的若干文件。 若答案里出现 domain 或 app,说明边界已经破了。
六边形:端口与适配器
六边形的贡献不是"画六个角",而是把"内部"与"外部"通过端口(port)隔离:
┌─────────── 外部世界 ───────────┐
HTTP ───▶[驱动适配器] [被驱动适配器]◀─── MySQL
gRPC ───▶[driver adapter] [driven adapter]◀── Redis
│ ▲
▼ 端口(port,接口) │
┌──────────────────────────────────────────┴──┐
│ 应用核心(domain + app) │
│ OrderService.Submit(ctx, cmd) (用例端口) │
│ OrderRepository.Save(ctx, o) (被驱动端口) │
└─────────────────────────────────────────────┘
- 驱动端口(driving port):外部调用系统的入口,如
OrderUseCase接口;被驱动端口(driven port):系统需要外部提供的能力,如OrderRepository、PaymentGateway、Clock。
端口是领域的语言,不是数据库的语言。FindByStatusAndCreatedAfter 比 Query(where map[string]any) 好,因为前者是领域需求,后者把查询能力暴露成了实现细节。
Go 项目目录结构示例
internal/
├── domain/ # 领域层:只依赖标准库
│ └── order/
│ ├── order.go # 聚合根与不变量(如金额必须为正)
│ ├── repository.go # 被驱动端口:Repository 接口定义在这里
├── app/ # 应用层:用例编排、事务边界
│ └── order/
│ ├── submit.go # SubmitUseCase:编排 + 事务
│ └── port.go # 驱动端口:UseCase 接口
├── adapter/
│ ├── http/ # 驱动适配器
│ │ └── order_handler.go
│ └── persistence/ # 被驱动适配器
│ └── order_repo_mysql.go # 实现 domain/order.Repository
└── bootstrap/ # 组装:唯一允许到处 import 的地方
└── wire.go
对应的接口与实现:
// domain/order/repository.go —— 端口由领域定义
package order
type Repository interface {
FindByID(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, o *Order) error
}
// app/order/submit.go —— 应用层只依赖抽象
package order
type SubmitUseCase struct {
repo order.Repository // 领域端口,不是 *gorm.DB
pay PaymentGateway // 另一被驱动端口
events EventPublisher // 事件发布端口
}
func (u *SubmitUseCase) Submit(ctx context.Context, cmd SubmitCmd) (string, error) {
o, err := order.New(cmd.UserID, cmd.Items) // 领域不变量在 domain 内校验
if err != nil {
return "", err
}
if err := u.repo.Save(ctx, o); err != nil {
return "", err
}
return o.ID, u.events.Publish(ctx, o.PullEvents())
}
// adapter/persistence/order_repo_mysql.go —— 适配器实现端口
type OrderRepoMySQL struct{ db *sql.DB }
func (r *OrderRepoMySQL) Save(ctx context.Context, o *order.Order) error { /* SQL 在这里 */ }
真正体会依赖倒置威力的时刻是写测试:SubmitUseCase 用手写的假(fake)仓库即可完整测试,不需要数据库、不需要容器。
依赖方向的治理手段
| 手段 | 做法 | 适用 |
|---|---|---|
| 包结构约定 | domain 不得 import infra / adapter | 所有项目,成本最低 |
| CI 静态检查 | 用 go list 或依赖检查工具在流水线里禁止违规 import | 团队规模上来后必做 |
| 接口归属 | 端口接口定义在调用方所在包 | 所有项目 |
| 组装集中 | 只有 bootstrap 允许 import 全部层 | Go 项目惯用做法 |
一条可以直接抄进 CI 的规则:internal/domain 与 internal/app 的依赖闭包里不允许出现 database/sql、gorm.io、redis、net/http(server)等基础设施包。规则可自动检查,就不会退化成口头约定。
常见误区
- 贫血模型 + 胖服务:实体只剩字段和 getter,业务规则全堆在应用层,领域层名存实亡;这是分层与六边形最常见的"假落地"。
- 端口包了一层 ORM:
Repository直接返回 GORM 模型,适配器等于零职责,换存储仍然要改领域。 - 每层都写一遍 DTO:接入层 DTO → 应用层 DTO → 领域对象 → 持久化模型,四份结构互转,改动一处要动四处。建议只在接入层与领域之间保留转换,持久化模型与领域对象尽量同构。
- 事务边界放错位置:在 handler 里开事务,导致一个事务跨多个用例;事务应落在应用层用例边界。
- 六边形全项目铺开:读多写少的后台管理功能也强行端口化,收益远小于成本;按模块重要性差异化落地。
小结:分层的可维护性来自依赖方向,而不是层的数量。做法是让 domain 只表达业务、把端口接口定义在调用方、把实现留在 adapter、把组装收进 bootstrap,并用 CI 规则守住这条线。依赖方向守住了,测试、替换基础设施和后续拆分都会变得便宜。