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 类型与接口 |
| 驱动适配器(入站) | api | HTTP 把请求翻译成对领域的调用 |
| 被驱动适配器(出站) | 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 脚手架与代码生成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。