main.go 塞满业务逻辑会拖垮迭代速度,因其成为“上帝包”导致测试难、编译慢、新人上手难;应按职责拆分到包级,接口定义前置且置于调用方包,严格控制依赖方向与 internal 使用范围。

为什么 main.go 里塞满业务逻辑会拖垮迭代速度
因为 Go 没有类和继承,包就是最基础的封装单元;一旦 main.go 直接调用数据库、HTTP 客户端、日志、配置解析,它就自动成了“上帝包”——谁都依赖它,谁都不敢动它。
常见错误现象:go test ./... 跑不起来,因为测试要启 HTTP server、连真实 DB;go build 编译慢,只改一行路由却要重编所有业务逻辑;新人想加个新 API,发现得先读懂 main.go 里嵌套三层的 http.HandlerFunc 闭包。
- 职责必须切到包级:HTTP 路由归
cmd或api包,业务逻辑进service,数据访问放repository - 接口定义前置:比如
service.UserRepository接口写在service包里,实现放在repository,避免实现细节泄漏 - 禁止跨包直接 new struct:不要在
api包里&service.UserService{...},用构造函数或依赖注入容器(哪怕只是简单传参)
internal/ 目录不是保险箱,放错位置照样耦合
internal/ 只是阻止外部模块 import,并不自动带来职责清晰。把所有 model、handler、dao 塞进 internal/pkg,和全扔 main.go 没本质区别。
使用场景:真正该放 internal/ 的,是那些「仅本项目使用、且被多个顶层包共同依赖」的底层能力,比如统一错误码 internal/xerror、通用分页结构 internal/pagination。
立即学习“go语言免费学习笔记(深入)”;
-
internal/model可以,但只放纯数据结构(无方法、无 DB tag),且命名带领域前缀,如UserDO(Data Object)、UserVO(View Object) -
internal/handler不推荐——HTTP handler 是胶水层,应属于cmd或api,不该被其他包 import - 如果
internal/xxx下的包被超过两个非internal包 import,说明它已具备复用价值,该考虑提成独立 module 或至少文档化契约
接口定义在哪儿,决定了谁承担修改成本
Go 的接口是隐式实现,但定义位置错位会让重构变成灾难。比如把 SendEmail 接口定义在 notification 包,而 user 包里直接 new 一个 notification.EmailSenderImpl,等于把通知实现细节钉死在用户业务流里。
参数差异:接口方法参数尽量用输入 DTO(如 SendEmailInput),别直接传 *User;返回值用输出 DTO 或 error,避免暴露内部 struct 字段。
- 接口定义优先放在**调用方所在包**:比如
service包需要发邮件,则service.EmailSender接口就该定义在service,由notification包去实现 - 实现包不能 import 调用方包,否则循环依赖;可通过
func NewEmailSender(...) service.EmailSender构造函数解耦 - 别为了“解耦”而抽象:没 2 个以上实现时,先写具体类型;接口是为应对变化而生,不是为设计模式打卡
测试失败第一反应不该是 mock,而是检查包边界
当你发现写个单元测试要 import database/sql、net/http、甚至 cmd,基本可以确定包结构已经失守。Go 测试快的本质,是能用纯内存对象跑完核心逻辑。
性能影响:一个本该 5ms 跑完的 service.CreateOrder 单元测试,因为依赖了真实 Redis 连接池,变成 300ms 且不稳定。
- 测试时若发现要 patch
time.Now、rand.Intn,说明时间/随机性等副作用没隔离到外层包(如clock、randgen) - HTTP handler 测试用
httptest.NewRequest+httptest.NewRecorder即可,别启动真实 server - 真正难测的,往往是跨包强依赖的函数(如
log.Fatal、os.Exit)——它们应该被包装成可替换的 interface,或直接用 panic+recover 在测试中捕获
复杂点在于:包边界不是靠目录名决定的,而是看 import 图里有没有从低层包指向高层包的箭头。画不出来依赖图的团队,大概率还在靠直觉拆包。










