Go包分层核心是依赖方向与可见性边界,非目录深度;internal/为私有复用区需职责收敛;pkg/须具契约意识并最小化导出;replace仅作渐进拆分脚手架。

包层级不能靠目录深度堆出来
很多人误以为多套一层 internal/service/user/v1 就算分层了,结果只是把耦合从代码里挪到了路径里。Go 的包层级本质是依赖方向 + 可见性边界,不是文件夹嵌套层数。真正有效的分层,是让 user 包只依赖 entity 和 repository 接口,而绝不反向依赖;repository 实现则放在 storage 包里,通过 interface 隔离。
常见错误现象:import "myapp/internal/user/storage" 出现在 user/service.go 里——这说明 storage 已经泄露为具体实现,而非抽象契约。
- 每个业务包(如
user)对外只暴露接口和 DTO,不导出 struct 字段或数据库模型 - 接口定义应放在调用方所在包(比如
user包里定义UserRepository),实现放在storage或external等适配层 - 禁止跨业务包直连实现:不允许
order包 importuser/internal/repo,必须通过user.UserRepository接口通信
internal/ 不是“所有私有代码的垃圾场”
internal/ 是 Go 编译器强制的访问控制机制,不是语义分类标签。把它当“私有区”用没错,但必须配合职责收敛——否则很快变成 internal/common、internal/utils、internal/helpers 这种三不管地带,谁都往里塞函数,谁都依赖它。
使用场景:仅用于本项目内复用、但不打算开放给外部模块的逻辑,比如配置加载器、HTTP 中间件链、统一错误包装器。
立即学习“go语言免费学习笔记(深入)”;
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
-
internal/config可被cmd/api和cmd/worker同时导入,但外部项目无法 import - 一旦某个
internal/xxx包被超过 3 个其他 internal 包强依赖,就要问:它是否已承担通用职责?该抽到pkg/里并加测试 - 别在
internal/下建internal/pkg——这违背了internal/和pkg/的语义分工
pkg/ 包必须带契约意识,不是工具集合
pkg/ 是你项目对外的 API 面孔。一个叫 pkg/util 的包,没人知道它能干什么、能不能升级、有没有并发安全。而 pkg/logger 或 pkg/httpx,名字就声明了契约范围和稳定性预期。
参数差异直接影响可维护性:比如 logger.Info() 如果接受 map[string]interface{},下游就得猜字段名;换成 logger.Info("user login", zap.String("user_id", id)),调用意图一目了然。
- 每个
pkg/xxx必须有完整文档注释,说明用途、线程安全性、错误返回策略 - 导出的函数/类型要最小化:只暴露使用者真正需要的,比如
httpx.Client而不是整个http.Client原始字段 - 避免 “万能参数”:不要设计
func DoSomething(opt ...interface{}),用结构体选项(functional option)替代
模块替换(replace)是渐进拆分的脚手架,不是长期方案
当你在 go.mod 里写 replace example.com/project/user => ./internal/domain/user,本质是在本地模拟一个独立模块的导入路径。这不是为了绕过 go mod,而是为将来真正拆库做准备——所有对 example.com/project/user 的引用,将来只需改 tag 版本,不用动代码。
容易踩的坑:替换后没跑 go test ./internal/domain/user/... 就合并,结果 CI 上因路径解析失败直接挂掉。
- 每次新增
replace,必须同步验证:该路径下能否独立go build、go test、go vet - 替换路径必须是相对路径,且不能跨
go.work边界(单模块项目无需go.work) - 上线前必须删掉
replace行,并发布真实版本号——否则别人go get会失败
user 包的接口定义,就能推断出它怎么跟数据库和外部服务打交道——不需要翻十层目录找实现。路径只是容器,契约才是骨架。

















