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

Go 项目模块化不是靠盲目拆包,而是围绕「职责收敛」和「依赖可控」来设计。没有统一模板,但有几条硬约束必须遵守:不允许循环导入、main 包不放业务逻辑、领域模型不能跨 domain 泄露。
如何划分 domain / internal / pkg 目录边界
这是最容易混乱的起点。三个目录不是按“大小”或“热度”分的,而是按「抽象层级」和「可见性」:
-
domain/:只放纯结构体、接口、核心业务规则(如User、OrderStatusTransition),不依赖任何外部库,也不含数据库字段标签或 HTTP 注解 -
internal/:实现层,包括internal/user(用户服务)、internal/order(订单服务)等子包,可依赖domain和pkg,但彼此之间禁止直接 import(用 interface 隔离) -
pkg/:工具性、可复用、无业务语义的代码,比如pkg/validator、pkg/httpx、pkg/trace;它可被internal和cmd引用,但不能引用internal或domain
常见错误是把数据库 model 放进 domain,或让 internal/user 直接调用 internal/order 的函数——这会立刻导致循环依赖或测试无法隔离。
为什么 service 层必须返回 domain 对象而非 DTO 或 db 实体
service 是业务逻辑的守门人,它的返回值决定了上层(API 或 job)能“看到什么”。如果 service 返回 *sqlc.UserRow 或 map[string]interface{},就等于把数据层细节和序列化逻辑泄露出去,后续加缓存、换 ORM、改 API 字段时全得跟着动。
立即学习“go语言免费学习笔记(深入)”;
- 正确做法: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) 在别处不可靠)。必须统一管理:
优六系统(全称:优六企服系统)是在Util6MIS基础上组合CMS等插件及子系统的综合信息化管理系统。 Util6MIS(软著全称:优六信息化管理框架系统)是一款免费的通用信息化快速开发框架,该框架可快速集成各类系统开发。 系统后台采用.NET6 + Layui作为UI支撑,操作界面简洁,项目结构清晰,功能模块化设计,支撑框架轻量高效,代码层级分离,注释完整,可快速重构,提高开发效率。
- 定义全局错误码枚举:在
pkg/errno下用 const 声明ErrUserNotFound = 40401、ErrOrderInvalid = 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 做集成测试。
真正难的不是目录怎么起名,而是每次新增一个函数前,问自己:它属于哪一层?它暴露了什么细节?它会让谁因此无法被替换?









