《Go 语言编程实战》1.2 依赖方向与接口边界

模块拆开了,但如果 infra 反向 import api、domain 依赖具体存储,边界等于白划。本节用 TaskHub 演示依赖倒置:接口定义在 domain,实现在 infra,组装在 cmd,并用 go list 与 import cycle 报错实测依赖方向被工具真实约束,附编译期接口断言写法。

1.2 依赖方向与接口边界

上一节把 TaskHub 拆成了四个模块,但「拆开」不等于「划清」。如果 domain 里直接 import 了 infra,或者 api 反过来被 infra 引用,模块目录还在,依赖关系却已经乱成一团麻——这种项目比单模块更难维护,因为它给了你「已经解耦」的错觉。

本节要建立一条能被执行、能自我检查的规则:依赖只能从外向内指,内层不认识外层。

本节给 TaskHub 的四个模块定死依赖方向,用接口把 domain 与具体存储解耦,并用 go list 与编译错误实测「方向」不是口头约定,而是编译器能拦住的硬约束。

1.2.1 什么叫依赖方向

先给一个坐标系。TaskHub 的四层从内到外是:

domain  ←  infra
   ↑
  api
   ↑
  cmd
  • domain 是最内层,定义实体和接口,不依赖任何其它模块。
  • infra、api 依赖 domain,但它们彼此不互相依赖。
  • cmd 是最外层,依赖所有层,负责把它们组装起来。

关键规则只有一句:箭头永远从外指向内。domain 不知道 infra 存在,domain 也不知道 api 存在。这条规则的价值在于:当你想替换存储引擎(内存 → Postgres)时,改的只是 infra,domain 一行不用动。

1.2.2 依赖倒置:接口该放哪

初学者最常见的写法是把接口定义在实现方:

// infra 包定义接口 —— 反例
package infra

type TaskRepository interface {
	Create(ctx context.Context, t domain.Task) error
	// ...
}

这样写的问题立刻暴露:api 想用这个接口,就得 import infra。于是 api → infra 一条边被引入,而 infra 又依赖 domain,依赖图变成 api → infra → domain。如果哪天 infra 出于某种原因要引用 api 的类型(比如回调),立刻成环。

正确做法是接口定义在消费方依赖的最内层,也就是 domain:

// domain 包定义接口 —— 正例
package domain

type TaskRepository interface {
	Create(ctx context.Context, t Task) error
	Get(ctx context.Context, tenantID, id string) (Task, error)
	List(ctx context.Context, tenantID string) ([]Task, error)
}

infra.MemoryTaskRepo 提供实现,但它自己不声明「我实现了 TaskRepository」——Go 是隐式接口,实现方不需要知道接口的存在。这种「接口在消费者侧、实现在提供者侧」的做法叫依赖倒置(Dependency Inversion),它让依赖箭头掉了个头:不是 domain 依赖 infra,而是 infra 依赖 domain 定义的契约。

1.2.3 用 go list 检查依赖方向

依赖方向不能靠读代码,得靠工具。go list -deps 列出某包的全部传递依赖:

$ GOTOOLCHAIN=go1.27.0 go list -deps example.com/taskhub/infra | grep taskhub
example.com/taskhub/domain
example.com/taskhub/infra

infra 的依赖里只有 domain,没有 api、没有 cmd——方向正确。再看 api:

$ GOTOOLCHAIN=go1.27.0 go list -f '{{.ImportPath}} -> {{join .Imports " "}}' \
    example.com/taskhub/domain example.com/taskhub/infra example.com/taskhub/api
example.com/taskhub/domain -> context errors time
example.com/taskhub/infra -> context example.com/taskhub/domain sync
example.com/taskhub/api -> encoding/json example.com/taskhub/domain net/http

三行读下来,domain 只依赖标准库,infra 和 api 都只依赖 domain,彼此不交叉。把这条命令写进 CI,就能在合并前拦住方向错误的提交。

1.2.4 反向依赖会怎样:import cycle

如果真有人把依赖写反,比如让 domain 去 import infra,Go 会直接拒绝编译。实测把下面这行加进 domain/bad.go:

// domain/bad.go —— 故意制造反向依赖
package domain

import _ "example.com/taskhub/infra"

编译报错:

package example.com/taskhub/domain
	imports example.com/taskhub/infra from bad.go
	imports example.com/taskhub/domain from memory.go: import cycle not allowed

报错信息里那条链 domain → infra → domain 一目了然。这就是 Go 比很多语言严格的地方:循环依赖是编译期错误,不是运行期隐患。你要做的不是「避免写出循环」,而是「设计出不产生循环的依赖方向」,让编译器替你把关。

1.2.5 编译期接口断言

隐式接口有个副作用:你写了个 MemoryTaskRepo,以为自己实现了 TaskRepository,结果漏了一个方法,直到某处把它当接口用才报错。更糟的情况是,接口在 domain,实现在 infra,中间隔着一个模块,编译错误可能出现在很远的地方。

解决办法是在实现方加一行编译期断言:

// infra/assert.go
package infra

import "example.com/taskhub/domain"

var _ domain.TaskRepository = (*MemoryTaskRepo)(nil)

var _ T = (*X)(nil) 是 Go 的惯用断言写法:把一个 *MemoryTaskRepo 赋给 domain.TaskRepository 类型的空白标识符。它不产生任何运行时开销(编译期就消解掉),但会在实现方所在的位置报错。实测故意漏掉 Create 方法:

infra/badassert.go:7:31: cannot use (*Broken)(nil) (value of type *Broken) as
domain.TaskRepository value in variable declaration: *Broken does not implement
domain.TaskRepository (missing method Create)

错误直接指到 infra 包,而不是等到某个调用点才炸。每个适配器(adapter)都该有这么一行断言,成本为零,收益是「接口变更时立刻知道哪个实现掉队了」。

1.2.6 接口大小的取舍

Go 社区有句话:「接口越大,抽象越弱」。TaskHub 的 TaskRepository 只有三个方法,这已经是刻意控制的结果。对比一下:

接口规模典型症状建议
1–3 个方法易实现、易 mock、语义清晰首选
4–7 个方法开始出现「实现方有一半方法用不上」考虑按用途拆开
8+ 个方法每个实现都冗长,mock 痛苦几乎一定要拆

api 的 handler 其实只用到 List。与其依赖整个 TaskRepository,不如让 handler 依赖一个更小的接口:

// api/handler.go —— 只声明自己真正需要的方法
type TaskLister interface {
	List(ctx context.Context, tenantID string) ([]domain.Task, error)
}

这样 handler 的测试只需实现一个方法,而不是三个。接口应该由消费者按需定义,而不是由生产者一次性定义全部——这是 Go 接口哲学与 Java 式「先定接口再定实现」最大的不同。

1.2.7 边界不只是模块,还有包

模块边界是物理的(go.mod 隔开),包边界是逻辑的(同一模块内用包划分)。TaskHub 的 domain 模块未来会继续长大,那时要按领域概念而不是技术分层分包:

分包方式例子评价
按技术分层domain/models、domain/interfaces不推荐,models 会成为什么都往里塞的杂物间
按领域概念domain/task、domain/project、domain/tenant推荐,每个包职责单一

技术分层的通病是:models 包会被所有包 import,最终变成第二个「什么都依赖它、它什么都不依赖」的核心——看似符合依赖方向,实则把内聚性打散了。

1.2.8 依赖方向的检查清单

把本节的可执行规则整理成一张表,便于在 code review 时逐条对照:

检查项期望工具
domain 是否依赖其它模块否go list -deps
接口是否定义在最内层是人工
适配器是否有编译期断言是grep var _
接口方法数是否 ≤ 3尽量人工
是否出现 import cycle否编译器
handler 是否依赖具体类型否人工

一个经验法则:如果内层模块的 go.mod 里出现了对外层模块的 require,方向就错了。domain 的 go.mod 应该永远只有 module 和 go 两行,最多再加几个纯工具库。

1.2.9 边界与测试的关系

依赖方向划对了,测试会变得异常轻松。api 的 handler 依赖 domain.TaskRepository 接口,测试时写一个内存 fake 即可,不需要起数据库:

// api/handler_test.go —— 用 fake 替代真实仓储
type fakeRepo struct {
	tasks []domain.Task
}

func (f *fakeRepo) Create(context.Context, domain.Task) error { return nil }
func (f *fakeRepo) Get(context.Context, string, string) (domain.Task, error) {
	return domain.Task{}, domain.ErrNotFound
}
func (f *fakeRepo) List(_ context.Context, tenantID string) ([]domain.Task, error) {
	return f.tasks, nil
}

如果 api 直接依赖 *infra.MemoryTaskRepo,你就只能拿真实实现来测,测试和实现绑死。「好不好测」是依赖方向对不对的最终判据:一个需要起一堆外部依赖才能测的 handler,多半是依赖方向出了问题。

1.2.10 给这套做法一个名字:端口与适配器

TaskHub 现在这套结构有个业界名字——端口与适配器(Ports and Adapters),也叫六边形架构。它的核心词汇只有两个:

  • 端口(Port):内层定义的接口,描述「系统能做什么」,对应 domain.TaskRepository。
  • 适配器(Adapter):外层的具体实现,描述「用什么做」,对应 infra.MemoryTaskRepo、未来的 infra.PostgresTaskRepo。

映射到 TaskHub 的模块:

六边形概念TaskHub 模块特征
领域核心domain零外部依赖,纯 Go 类型与接口
驱动适配器(入站)apiHTTP 把请求翻译成对领域的调用
被驱动适配器(出站)infra领域通过端口调用它,它不认识领域之外的任何东西
组合根cmd/taskhubd唯一把适配器接到端口上的地方

叫得出名字的好处是沟通成本降低:当同事问「这个 Postgres 实现该放哪」,你可以直接答「出站适配器,放 infra」,而不用每次都从头解释一遍。

1.2.11 什么时候可以打破边界

规则是为了大多数情况服务的,总会有例外。以下场景可以考虑让内层「认识」一点外层的东西,但都要有明确理由:

例外常见做法代价
领域需要记日志领域方法接收 *slog.Logger 参数领域被迫认识 log/slog,但标准库可接受
领域需要 ID 生成在 domain 定义一个 IDGenerator 端口推荐,保持零依赖
领域需要事务边界在 domain 定义 TxManager 端口,infra 实现推荐,比让领域 import database/sql 干净
共享纯数据类型(DTO)单开一个 contract 模块,谁都能依赖可接受,但别让它反向依赖

共同点是:优先用「再加一个端口」解决,而不是让内层直接 import 外层。只有标准库(context、time、errors)才允许领域直接依赖——它们是语言的一部分,不是外部系统。

1.2.12 边界带来的一个副作用

划清依赖方向还有一个容易被忽略的收益:编译速度。当 domain 不依赖任何东西时,改 domain 只会重编依赖它的少数几个包;而如果全仓是一个巨型单模块,任何一个文件的改动都可能触发大面积重编。

本机实测(缓存已热,含 go 命令自身启动开销):

$ time GOTOOLCHAIN=go1.27.0 go build example.com/taskhub/domain
real    0m0.62s

$ time GOTOOLCHAIN=go1.27.0 go build all
real    0m1.72s

单独构建最内层约 0.6 秒,构建整个工作区约 1.7 秒。TaskHub 现在还小,差距不明显;但当 domain 有几百个类型、infra 有十几个适配器时,「内层零依赖」意味着改一个业务规则不需要重编任何数据库驱动代码。

下一节我们处理多模块带来的一个新麻烦:当四个模块共享同一套模板、同一份枚举、同一套 API 类型时,手写会重复、会漂移。解法是脚手架与代码生成。

阅读导航:上一节:1.1 单仓多模块与 go.work · 下一节:1.3 脚手架与代码生成 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练