main.go 应放在 cmd/ 目录下,如 cmd/myapp/main.go,仅负责初始化并启动服务;避免根目录混乱、提升可维护性与多二进制支持。
很多人一上来就把 main.go 扔项目根目录,结果随着路由、中间件、配置越来越多,根目录迅速变成垃圾场。Go 项目不是脚本,main.go 应该只做一件事:初始化并启动服务。它属于「入口层」,理应放在 cmd/ 下,比如 cmd/myapp/main.go。这样既和业务逻辑隔离,也方便同一仓库下共存多个可执行程序(如 CLI 工具、migration 命令)。
常见错误是把数据库初始化、配置加载、路由注册全塞进 main.go —— 这会导致测试困难、复用性差、启动逻辑无法被单元测试覆盖。
cmd/:只放可执行入口,每个子目录对应一个 binary(cmd/api、cmd/migrate)internal/:所有不对外暴露的业务代码都放这里(internal/handler、internal/service、internal/repository)pkg/:仅当有明确跨项目复用意图时才放通用工具包(如自定义日志封装、HTTP 客户端基类),否则别滥用Go 没有强制分层,但直连 database/sql 或 ORM 实例(如 gorm.DB)到 handler,会带来三个硬伤:难以 mock 测试、事务边界模糊、SQL 泄露到 HTTP 层。正确的做法是让 handler 只负责解析请求、校验参数、调用 service、构造响应。
例如一个用户创建接口,handler 解析 json 后,应把干净的结构体传给 service.CreateUser(),而不是自己

INSERT 语句或调用 db.Create()。
handler 接收 *http.Request,返回 http.ResponseWriter,不 import 任何数据库相关包service 层定义接口(如 UserRepository),实现由 repository 提供,便于替换底层存储或加缓存service 层显式开启(如 tx := db.Begin()),而非在 handler 或 repository 中隐式传播配置不应在 init() 函数里读取,也不该在 main() 开头就一次性全部解析完然后全局变量存着。真实项目中,你很可能需要:按环境加载不同文件(config.development.yaml)、支持从环境变量覆盖字段、甚至运行时重载日志级别或 feature flag。
推荐用 github.com/spf13/viper,但要注意三点:
cmd/myapp/main.go 中初始化 viper,设置路径、前缀、自动重载(viper.WatchConfig())internal/config/config.go,用 viper.Unmarshal() 绑定,避免散落各处的 viper.GetString()
MaxOpenConns)必须在 sql.Open() 之后立刻设置,不能等第一次查询时才生效Go 的测试惯例是「测试文件与被测文件同目录,_test.go 结尾」。新建一个 test/ 目录集中放测试,反而破坏了 Go 工具链对测试的识别(go test ./... 仍能跑,但 IDE 跳转、覆盖率统计、go list -f '{{.TestGoFiles}}' ./... 都会出问题)。
正确方式是让每个包自己管自己的测试:
internal/handler/user_handler.go → 对应 internal/handler/user_handler_test.go
internal/handler/integration_test.go,用 //go:build integration 标签隔离gomock 生成,但别为每个 repository 都生成——先写测试,发现依赖难 mock 再补 interface,避免过度设计package handler
import (
"net/http"
"testing"
)
func TestCreateUser(t *testing.T) {
// 构造 fake service,不碰真实 DB
svc := &fakeUserService{}
h := NewUserHandler(svc)
req := httptest.NewRequest("POST", "/users", strings.NewReader(`{"name":"a"}`))
w := httptest.NewRecorder()
h.CreateUser(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected 201, got %d", w.Code)
}
}
真正容易被忽略的是:HTTP handler 的 error 处理粒度。很多人用一个全局 http.Error() 包裹所有错误,导致前端拿不到具体错误码(如 400 vs 409)。应该在 service 层返回带状态码的 error(如自定义 AppError{Code: 409, Msg: "email exists"} ),再由 handler 统一转换。这比后期加中间件拦截 panic 更可控。