泛型Repository[T]在初期是负优化,应先为具体业务实体(如UserRepo)写死接口,待出现3个以上签名语义完全一致的仓库、底层实现高度雷同且模式稳定时,再抽象泛型。

别写泛型 Repository[T] 接口——至少在第一个业务实体落地前不要碰。
先写死,再抽象:为什么泛型接口在初期是负优化
刚用 GORM 写 UserRepo 就急着定义 type Repository[T any] interface { Create(*T) error; FindByID(int) (*T, error) },结果三个月后发现:User 需要按邮箱查、Order 要按客户 ID + 时间范围查、Payment 还得支持幂等键去重——所有方法签名都对不上。泛型接口强行统一,反而把业务语义抹平了。
- 接口方法名必须带业务动词,如
FindActiveByCustomerID,不是FindBy - 返回类型必须是具体结构体指针,如
*User,不能是interface{}或any - 参数必须是完整结构体(
Create(u *User))或明确业务条件(FindUnshippedSince(days int)),不接受map[string]interface{} - 错误必须封装,如
ErrNotFound,绝不透传gorm.ErrRecordNotFound或sql.ErrNoRows
GORM 实现里怎么避免“SQL 拼接”和“事务错位”
很多人以为用了 GORM 就安全了,结果写出 db.Where("status = '" + status + "'").Find(&users) ——这行代码同时触发 SQL 注入、PostgreSQL 类型报错、预编译失效。更隐蔽的问题是事务:在 Service 层开启事务后,直接调用未接收 *gorm.DB 参数的 repo.Create(),实际走的是全局连接,事务完全丢失。
- 所有查询必须用 GORM 的链式方法或
squirrel构建,禁止fmt.Sprintf和字符串拼接 - 需要事务的 Repository 方法,签名必须显式接收
*gorm.DB,例如CreateInTx(tx *gorm.DB, u *User) error - 避免在 Repository 实现里调用
db.Transaction()——事务边界必须由Service控制 - 复杂条件优先用
squirrel,它生成的 SQL 可读、可测、可复用,比手写Where("created_at > ? AND status IN (?)", ...)更可靠
什么时候才该抽泛型?看这三个信号
泛型不是“高级语法炫技”,而是为解决重复模式而生。当出现以下任意一种情况,再考虑抽象:
- 你已写出 3 个以上结构相似的 Repository 接口(如
UserRepo、ProductRepo、CategoryRepo),且它们都有Create、Update、FindByID三个方法,签名和语义完全一致 - 这些方法底层实现逻辑高度雷同(比如都用
tx.Create()、tx.First(),没任何分支或定制) - 你确认未来新增的实体不会打破这个模式(例如不会出现某个实体需要软删除、另一个需要唯一索引冲突重试)
即便满足,也建议先用代码生成器(如 gnorm)产出具体实现,再从生成结果反推泛型约束,而不是凭空设计 type Repository[T Entity, ID comparable] ——Go 的泛型约束容易写得太宽或太窄,一改就是全量重构。
测试时 mock 的关键点:接口定义位置决定可测性
很多团队写了 UserRepository 接口,却把它放在 internal/infra/db 目录下,导致 Service 层依赖实现路径,根本没法注入 mock。真正的解耦只有一条路:接口必须定义在调用方所在层(通常是 internal/domain 或 internal/service),而 GORM 实现放在 internal/infra/db/gorm。
- 单元测试时,用
gomock或手写 struct 实现接口,完全绕过数据库 - 集成测试用 SQLite 内存模式:
gorm.Open(sqlite.Open(":memory:"), ...),配合AutoMigrate快速建表 - 绝不测试
db.Where(...).Find()是否执行成功——那是 GORM 自己该测的;你只测“给定输入,Repository 是否返回预期结构体或错误”
最常被忽略的细节:接口方法签名里如果有 context.Context,mock 实现必须原样透传,不能丢弃或替换——否则超时控制、trace propagation 全失效。


















