domain层禁止使用time.Time或sql.NullString,因其违背技术无关原则;正确做法是用自定义类型封装语义、指针表示空值,并通过Clock接口解耦时间生成。

domain 层为什么不能出现 time.Time 或 sql.NullString
因为 domain 层必须完全脱离技术细节,否则业务逻辑就和数据库或标准库绑死了。一旦你写 CreatedAt time.Time,就等于把时间实现细节暴露给了所有用例和测试;用 sql.NullString 更糟——它直接依赖 database/sql,导致你连单元测试都构造不出合法的 User 实例。
正确做法是:用基础类型或自定义类型封装语义。比如:
type CreatedAt string // 或 type CreatedAt time.Time(自定义类型,不直接 alias 标准库)
type User struct {
ID string
Name string
CreatedAt CreatedAt // 不是 time.Time
Email *string // 空值用 *string,不是 sql.NullString
}
时间生成交给外层注入的 Clock 接口,空值语义统一用 Go 原生指针,mock 仓库时直接赋 nil 或 &"test@example.com" 即可。
repository 接口必须定义在 domain 或 usecase 包里
Go 没有包级访问控制,依赖方向全靠目录约束。如果把 UserRepository 接口放在 infrastructure 或 repository 目录下,usecase 就得 import 下层,违反依赖倒置。
立即学习“go语言免费学习笔记(深入)”;
-
domain/user_repository.go定义接口:type UserRepository interface { FindByID(id string) (*User, error) } -
infrastructure/postgres/user_repo.go实现它,importdomain,但domain绝不 importinfrastructure - 接口方法只暴露业务语义,不带 SQL 细节:禁止
QueryRaw、Scan、Exec等词 - 返回值必须是 domain 实体,不能是
*sql.Rows或gorm.DB
cmd/main.go 只能做三件事:解析 flag、调 app.Run()、返回 error
任何初始化逻辑塞进 main.go 都会让测试失效。比如在 main.go 里调 viper.ReadInConfig(),测试时就绕不开文件系统;直接 new sql.DB,就等于把数据库连接硬编码进入口,无法替换成内存仓库。
应该把所有初始化下沉到 internal/app:
- 配置加载、DB 连接池创建、HTTP 路由注册 → 全部在
app.NewApp()里完成 -
cmd/api/main.go只保留:app := app.NewApp(...); return app.Run() - 禁止
cmdimportinternal/infrastructure或internal/repository - 若要用 gRPC 或 RabbitMQ,客户端初始化也必须在
app层,而非cmd
service 层不能碰 http.Request、gin.Context 或 sql.Tx
service 是纯业务协调者,它的输入输出必须是领域对象。一旦出现 func Register(c *gin.Context) 或 tx.Exec(...),这个 service 就再也无法被 CLI、定时任务或 gRPC 复用——它已经不是业务逻辑,而是胶水代码。
正确结构是:
- 输入:用例请求结构体,如
type CreateUserReq struct { Name string; Email string } - 输出:领域实体或错误,如
func (s *CreateUserSvc) Execute(req CreateUserReq) (*domain.User, error) - 数据库操作交给 repository 接口,事务边界由外层(如 HTTP handler 或 gRPC server)控制
- 权限校验、限流、日志等横切关注点,通过 middleware 或 decorator 注入,不污染 service
真正容易被忽略的是:service 方法命名要体现业务动作(CreateUser),而不是技术动作(SaveToDB);每个 service 文件只对应一个明确用例,拒绝 UserUsecase 这种大而全的 struct。


















