分层与六边形架构:依赖方向决定可维护性

分层架构之所以在大项目里失效,几乎都栽在同一件事上:依赖方向反了。领域逻辑里出现 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 规则守住这条线。依赖方向守住了,测试、替换基础设施和后续拆分都会变得便宜。

笔记加载中…