如何为Golang项目设计基础模块_Golang项目模块化架构方案

Go项目模块化需遵循职责收敛与依赖可控,严格划分domain(纯业务结构)、internal(实现层,禁止跨包直接调用)、pkg(通用工具),service返回domain对象而非DTO或DB实体,错误统一用errno管理,main.go仅负责依赖组装。

Go 项目模块化不是靠盲目拆包,而是围绕「职责收敛」和「依赖可控」来设计。没有统一模板,但有几条硬约束必须遵守:不允许循环导入、main 包不放业务逻辑、领域模型不能跨 domain 泄露。

如何划分 domain / internal / pkg 目录边界

这是最容易混乱的起点。三个目录不是按“大小”或“热度”分的,而是按「抽象层级」和「可见性」:

  • domain/:只放纯结构体、接口、核心业务规则(如 UserOrderStatusTransition),不依赖任何外部库,也不含数据库字段标签或 HTTP 注解
  • internal/:实现层,包括 internal/user(用户服务)、internal/order(订单服务)等子包,可依赖 domainpkg,但彼此之间禁止直接 import(用 interface 隔离)
  • pkg/:工具性、可复用、无业务语义的代码,比如 pkg/validatorpkg/httpxpkg/trace;它可被 internalcmd 引用,但不能引用 internaldomain

常见错误是把数据库 model 放进 domain,或让 internal/user 直接调用 internal/order 的函数——这会立刻导致循环依赖或测试无法隔离。

为什么 service 层必须返回 domain 对象而非 DTO 或 db 实体

service 是业务逻辑的守门人,它的返回值决定了上层(API 或 job)能“看到什么”。如果 service 返回 *sqlc.UserRowmap[string]interface{},就等于把数据层细节和序列化逻辑泄露出去,后续加缓存、换 ORM、

改 API 字段时全得跟着动。

  • 正确做法:service 方法签名始终返回 domain.User[]domain.Order 等类型
  • 转换动作收口在 handler 或 adapter 层(如 http/handler/user.go 调用 userSvc.GetByID() 后,再映射到 http.UserResponse
  • 好处:单元测试只 mock service 接口,不关心 JSON tag、gRPC proto 或 GORM struct tag 怎么写

如何安全地共享 error 类型和错误码

Go 的 error 是值,不是类,所以不能靠类型断言跨包识别业务错误(比如 errors.Is(err, user.ErrNotFound) 在别处不可靠)。必须统一管理:

  • 定义全局错误码枚举:在 pkg/errno 下用 const 声明 ErrUserNotFound = 40401ErrOrderInvalid = 40002
  • 所有 error 构造使用 errno.New(ErrUserNotFound, "user not found"),该函数返回实现了 errno.Coder 接口的 error
  • handler 中统一用 err.(errno.Coder).Code() 提取码,不依赖字符串匹配或包路径

否则你会在日志里看到一堆 "user not found",却无法区分是 auth 模块还是 user 模块抛的,也无法做精细化监控告警。

main.go 只负责组装,不写任何逻辑

cmd/yourapp/main.go 应该薄得像张纸:初始化 config → 构建依赖树(DB、cache、logger)→ 注册 service 实例 → 启动 HTTP/gRPC server。里面不能出现 if/else、SQL 查询、HTTP 请求、甚至 fmt.Println。

func main() {
	cfg := config.Load()
	db := postgres.New(cfg.DB)
	userRepo := postgres.NewUserRepo(db)
	userSvc := user.NewService(userRepo) // 依赖注入完成
	srv := http.NewServer(cfg.HTTP, userSvc)
	srv.Run()
}

一旦 main.go 开始处理业务分支或调用第三方 API,模块边界就塌了——你将失去独立启动某个子服务的能力,也很难对单个 domain 做集成测试。

真正难的不是目录怎么起名,而是每次新增一个函数前,问自己:它属于哪一层?它暴露了什么细节?它会让谁因此无法被替换?