应按业务域而非技术层划分包结构,如internal/user、internal/order,每包内含handler.go、service.go等;用internal限制可见性;service依赖接口而非具体实现;模块边界依限界上下文持续演进。

按业务域建子包,别按技术层堆目录
把所有 handler 放一起、所有 service 放一起,看似整齐,实际一加新功能就乱套:你想改“订单取消时扣库存”,得在 handler/、service/、repo/ 三个目录里跳来跳去,还容易误动其他模块的代码。真正可维护的做法是——每个业务模块自成一包,比如 internal/user、internal/order,包内再放 handler.go、service.go、repository.go、model.go。这样你找“用户登录逻辑”,直接进 internal/user 就行,不用猜它藏在哪一层。
- IDE 搜索范围大幅缩小,
Ctrl+Click能直接跳到同模块的 service 或 repo - 新增一个“优惠券”模块?新建
internal/coupon/目录,复制粘贴结构即可启动,不污染全局 - 团队分工更自然:A 负责 user,B 负责 order,彼此目录隔离,几乎不会产生合并冲突
用 internal/ 锁死业务包可见性
Go 的 internal 是语言级保护机制,不是命名习惯。放在 internal/user 下的包,外部项目(包括同仓库其他模块)根本 import 不进来。这点必须用上,否则你会看到同事在 cmd/admin 里直接调用 user/repository,绕过 service 层校验,埋下数据一致性隐患。
-
internal/下的包只允许被本项目其他internal/包或cmd/下的 main 包引用 - 别把工具函数塞进
internal/util—— 它会迅速变成黑洞,删一个函数得全项目 grep,怕漏掉依赖 - 真需要复用?提出来放
pkg/,但必须有明确接口、文档和测试,不是“先扔进去再说”
service 层必须依赖接口,不是结构体
写 service 时直接 new 一个 userRepository 结构体,测试时就只能跑真实数据库,或者写一堆反射 mock。正确姿势是在 internal/user/service.go 里定义接口,比如:
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
Update(ctx context.Context, u *User) error
}
然后让 service 接收该接口作为参数(构造函数注入或方法参数)。这样单元测试时传个 fake 实现就行,完全脱离数据库。
立即学习“go语言免费学习笔记(深入)”;
- 接口定义放在使用方(service 包),而不是 repository 包——避免循环引用
- 一个 service 可以组合多个接口(如
UserRepository+NotificationService),但每个接口只暴露它该干的事 - 别为了“看起来解耦”而抽象出
UserRepoInterface这种冗余名字,就叫UserRepository
模块边界模糊时,用 DDD 的限界上下文划线
当“用户积分”该属于 user 还是 reward 模块犹豫不决,说明业务语义没厘清。这时候别拍脑袋,回到业务本身:积分变动是否总伴随用户等级变更?积分兑换是否触发订单创建?如果答案是“是”,那它大概率是订单或营销域的一部分,不该塞进 user 包。
- 每个子包对应一个限界上下文(Bounded Context),比如
internal/payment就只管支付成功/失败、对账、退款,不碰“用户余额展示”这种展示逻辑 - 跨上下文的数据同步,走事件(event)或 API 调用,绝不直接 import 对方的 repository
- 初期可以小步快跑,但一旦发现两个模块频繁互相调用、共用 model、共享 db 表,就是边界错位的明确信号
最常被忽略的点是:模块划分不是一次性设计任务,而是随着需求演进持续调整的过程。今天拆得再干净,三个月后加了“社交关系链”,可能就得把部分 user 逻辑抽成 internal/friendship —— 关键不是一步到位,而是每次修改都让边界更清晰一点。










