Go项目模块化核心是防止业务逻辑耦合,应按业务域(如user/、order/)组织包,严格隔离internal/与pkg/,跨模块通信走domain event,依赖显式注入,边界由go build验证。

Go 项目模块化不是为了“看起来整洁”,而是防止半年后改一行用户登录逻辑,结果发现要动 user、auth、notification、analytics 四个包,还搞不清谁调了谁的私有函数。
按业务域组织目录,别用 controllers/services/repositories
看到顶层有 controllers/、services/、repositories/ 目录,基本意味着模块边界已经模糊。不同业务的 handler 全挤在一个目录里,改个订单状态就得翻三四个文件,还容易误删别人写的校验逻辑。
正确做法是每个业务功能自成一包:user/、order/、payment/,包内再按职责拆文件(如 user/service.go、user/repo.go)。user/ 包对外只暴露 UserService 接口和 CreateUserRequest 类型,不导出数据库模型或中间件实现。
- 跨业务调用必须走接口,禁止
import "xxx/internal/order/repo"这类直连内部实现的写法 - 如果某个包开始导入超过 3 个其他业务包,说明它已承担过多协调职责,该拆或重定义边界
-
cmd/api/main.go只做四件事:解析 flag、加载internal/config、构建internal/server实例、调用.Run()
用 internal/ 和 pkg/ 划清可见性边界
internal/ 是 Go 工具链强制的包级隔离机制——任何路径含 /internal/ 的包,都无法被本项目以外的 module 导入。它适合放纯私有逻辑:internal/config、internal/middleware、internal/server,可被 cmd/api 和 cmd/worker 共用,但绝不允许外部项目 import。
立即学习“go语言免费学习笔记(深入)”;
pkg/ 放真正可复用的公共能力:pkg/logger、pkg/httpx、pkg/uuid,这些包要有完整单元测试和文档注释。
- 别把日志封装塞进
pkg/common这种模糊命名里——pkg/logger才能让人一眼明白用途和契约 - 一旦发现
pkg/下某个包被 3 个以上业务模块强依赖,就要警惕它是否在演变成“上帝工具包”,考虑按场景进一步拆分 - 新增包须立即验证依赖,避免出现隐式循环引用(比如
user→notification→user)
跨模块通信必须通过 domain event 或 message bus
避免在 handler 内部直接调用另一个模块的 service 方法。比如用户创建后要发短信、更新统计、触发风控,这些都不该在 user/service.go 里硬编码调用 notification.Send() 或 analytics.Inc()。
应该由 user 模块发布 UserCreatedEvent,其他模块监听处理。事件主题名统一定义为常量:const UserCreatedTopic = "user.created"。
- 内存内总线必须封装类型安全、带生命周期管理、非阻塞投递——裸用
chan interface{}必 crash:并发写未加锁 map、goroutine 泄漏、接收循环阻塞 - 每个 handler 必须包裹
recover()+ 日志,并运行在独立 goroutine 中;关键调用前检查ctx.Err() - 当需要跨进程通信、要求事件不丢失、单机 goroutine 持续 > 5000 时,该换 Kafka / Redis Streams,而不是硬撑内存总线
依赖注入必须显式传参,别靠全局变量或 init()
Go 没有运行时 DI 容器,依赖必须通过构造函数或工厂函数显式传入。例如:
<pre class="brush:php;toolbar:false;">type UserService struct {
db *sql.DB
emailSender EmailSender
}
func NewUserService(db *sql.DB, sender EmailSender) *UserService {
return &UserService{db: db, emailSender: sender}
}
这样写才能保证单元测试时可替换 mock 实现,也避免了 init() 函数悄悄初始化 DB 连接、导致测试环境无法控制依赖生命周期的问题。
- 手动 DI 在中等规模项目够用;超大型项目可引入
github.com/google/wire生成编译期注入代码,避免反射开销 - 所有中间件注册、路由绑定、健康检查端点,都下沉到
internal/server包里,通过函数选项(functional option)注入依赖 - 如果某个模块的构造函数参数超过 4 个,说明它职责过重,该拆或引入配置结构体封装
最常被忽略的一点是:模块边界的约束力不来自目录结构,而来自 go build 能否成功。只要一个 import 能通过,解耦就还没到位。每次新增跨包调用前,先问一句:这个依赖真的不可替代吗?还是只是图一时方便?


















