Go项目模块化需遵循职责收敛与依赖可控,严格划分domain(纯业务结构)、internal(实现层,禁止跨包直接调用)、pkg(通用工具),service返回domain对象而非DTO或DB实体,错误统一用errno管理,main.go仅负责依赖组装。
Go 项目模块化不是靠盲目拆包,而是围绕「职责收敛」和「依赖可控」来设计。没有统一模板,但有几条硬约束必须遵守:不允许循环导入、main 包不放业务逻辑、领域模型不能跨 domain 泄露。
这是最容易混乱的起点。三个目录不是按“大小”或“热度”分的,而是按「抽象层级」和「可见性」:
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 是业务逻辑的守门人,它的返回值决定了上层(API 或 job)能“看到什么”。如果 service 返回 *sqlc.UserRow 或 map[string]interface{},就等于把数据层细节和序列化逻辑泄露出去,后续加缓存、换 ORM、改 API 字段时全得跟着动。
domain.User、[]domain.Order 等类型http/handler/user.go 调用 userSvc.GetByID() 后,再映射到 http.UserResponse)Go 的 error 是值,不是类,所以不能靠类型断言跨包识别业务错误(比如 errors.Is(err, user.ErrNotFound) 在别处不可靠)。必须统一管理:
pkg/errno 下用 const 声明 ErrUserNotFound = 40401、ErrOrderInvalid = 40002
errno.New(ErrUserNotFound, "user not found"),该函数返回实现了 errno.Coder 接口的 errorerr.(errno.Coder).Code() 提取码,不依赖字符串匹配或包路径否则你会在日志里看到一堆 "user not found",却无法区分是 auth 模块还是 user 模块抛的,也无法做精细化监控告警。
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 做集成测试。
真正难的不是目录

来电咨询