Go包名应使用简洁、小写的单数形式,如user、http;拆包依据是“可独立演进”,非功能分层;internal/为私有实现,pkg/为可复用库,cmd/为入口;接口应定义在调用方或抽象包中。

包名应该用单数还是小写?
Go 语言规范明确要求包名必须是合法的标识符,且惯例是使用简洁、小写的单数形式,比如 user、http、sql。不要用复数(users)、驼峰(userHandler)或下划线(user_repo)。因为包名会出现在所有导入后的调用中,例如 user.New() 比 users.NewUser() 更自然,也避免和类型名重复造成混淆。
- 错误示例:
package users→ 导入后变成users.User{},语义冗余 - 正确示例:
package user→user.User{}或user.New(),清晰无歧义 - 包名不强制与目录名一致,但绝大多数项目都保持一致;若不一致,需在
go.mod中确保模块路径能解析到该包
何时该拆出新包?不是按功能层,而是按“可独立演进”
常见误区是机械照搬 MVC 或 Clean Architecture 的目录结构,把 handler、service、repository 强行分包。Go 的包边界核心标准是:是否具备独立的依赖、测试、版本控制和演化节奏。一个包如果总是和另一个包一起修改、一起发布、无法单独测试,那它大概率不该拆。
- 适合拆包的信号:
go test ./pkg/xxx能跑通且不依赖其他业务包;go list -f '{{.Deps}}' ./pkg/xxx显示只依赖标准库或稳定第三方(如github.com/google/uuid) - 反模式:
internal/handler里全是 HTTP 相关逻辑,但每个 handler 都强依赖internal/service和internal/repository—— 这三者实际是一个演化单元,合并在internal/api包里更合理 - 典型合理拆分:
domain(纯结构+方法,零外部依赖)、storage(封装 SQL/Redis 实现,依赖database/sql但不依赖业务逻辑)
internal/ vs pkg/ vs cmd/:这三个目录的真实分工
Go 官方推荐的顶层结构不是教条,而是解决具体问题的工具:internal/ 是私有实现边界,pkg/ 是可被外部复用的库,cmd/ 是可执行入口。混用会导致依赖泄漏或复用困难。
-
internal/下的包不能被本项目以外的模块 import —— Go 编译器强制检查,适合放领域模型、应用服务、基础设施适配器等专用于当前项目的代码 -
pkg/应该像第三方库一样设计:有清晰 API、导出类型最小化、带文档注释、可独立go test;例如pkg/email提供Send(ctx, to, subject, body),内部用 SMTP 或 SendGrid 都不影响调用方 -
cmd/只做三件事:解析 flag / env、初始化依赖(DB、logger、config)、调用main.Run();每个命令一个子目录,如cmd/myapp、cmd/migrate,便于构建多个二进制
接口定义放在哪?别在实现包里 export interface
Go 没有“接口必须提前声明”的约束,但把接口和实现耦合在同一包里,会锁死扩展能力。正确做法是让接口由使用者定义,或放在更抽象的包中。
- 错误做法:
storage/postgres.go里定义type UserRepo interface { GetByID(id int) (*User, error) },然后postgres.UserRepoImpl实现它 —— 外部无法替换实现,且测试只能用 mock 或真实 DB - 推荐做法:在
domain/或internal/port/中定义type UserRepository interface,storage/postgres包只 import 并实现它;调用方(如internal/app)只依赖domain包,完全不知道 PostgreSQL 存在 - 额外好处:运行
go list -f '{{.Imports}}' ./internal/app会显示只依赖domain,不出现storage/postgres,证明依赖方向正确
package domain
type User struct {
ID int
Name string
}
type UserRepository interface {
GetByID(id int) (*User, error)
Save(u *User) error
}
package postgres
import "myproject/domain"
type repo struct {
db *sql.DB
}
func (r *repo) GetByID(id int) (*domain.User, error) {
// 实现细节
}
// 注意:这里不 export repo 或 UserRepository
// 而是通过工厂函数返回 interface{}
func NewUserRepository(db *sql.DB) domain.UserRepository {
return &repo{db: db}
}
真正容易被忽略的,是包的「演化成本」:一个包一旦被多个地方 import,它的任何导出变更(哪怕只是加个方法)都可能引发连锁重构。所以别为了“看起来整洁”而早拆包,先让代码在同一个包里跑通核心流程,再根据测试隔离性、部署粒度、团队协作节奏,逐步识别出真正的边界。










