应按职责分包而非功能模块,main.go仅留15行启动逻辑;service层前置接口定义,handler依赖接口而非具体实现;配置通过结构体传参实现依赖注入;错误处理用自定义错误类型替代fmt.Errorf包装。

从 main.go 开始就该拆包,别堆逻辑
Go 初级项目最常见问题是把所有代码塞进 main.go:HTTP 路由、数据库初始化、配置加载、业务 handler 全挤在一起。这导致测试难写、改动易崩、新人看不懂入口在哪。
实际做法是按职责分包,不是按功能模块名硬套(比如别一上来就建 user/、order/),而是先切出这几层:
-
cmd/:只放main.go和root.go(含flag解析),不写任何业务逻辑 -
internal/下分app/(启动协调)、handlers/(只做参数解析和响应包装)、service/(核心业务规则)、repository/(数据访问,隔离 SQL 或 ORM 细节) -
pkg/放可复用的工具,比如pkg/validator、pkg/httpx,确保它不依赖internal/
这样拆完,main.go 通常只剩 15 行以内:初始化配置 → 构建依赖 → 启动 HTTP server。改路由不影响数据库连接池配置,换 ORM 不用动 handler。
接口定义要前置在 service 层,别让 handler 直接调 db
很多新手写 handler 时直接 new sql.DB 或调 gorm.Model().Where().First(),结果导致:
- 单元测试只能走真实 DB,跑得慢还污染数据
- 换 PostgreSQL 为 SQLite 做本地测试时,SQL 方言差异直接报错
- 加缓存逻辑(比如先查 Redis)必须改所有 handler,违反开闭原则
正确做法是在 service/ 定义接口,例如:
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, u *User) error
}
然后在 repository/ 下实现 postgresUserRepo 和 mockUserRepo(用于测试)。handler 只依赖 UserRepository 接口,不关心底层是 SQL 还是内存 map。
注意:接口方法参数别传 *sql.Tx 或 *gorm.DB,那是实现细节;用 context.Context + 普通值即可。
配置初始化别用全局变量,用结构体传参
看到 var cfg Config 在包顶层,然后各处 import 包后直接读 cfg.DB.Host —— 这是 Go 初级项目最隐蔽的耦合源。问题包括:
- 无法为不同环境(test / staging)注入不同配置
- 写集成测试时,改了
cfg会影响其他测试用例 - 启动时配置校验失败,错误堆栈指向
init()函数,难定位
改成显式构造:
type App struct {
cfg Config
repo UserRepository
cache CacheClient
}
func NewApp(cfg Config, repo UserRepository, cache CacheClient) *App {
return &App{cfg: cfg, repo: repo, cache: cache}
}
func (a *App) Run() error {
// 启动逻辑
}
这样每个依赖都清晰可见,DI(依赖注入)自然形成,测试时直接传 mock 对象,不用重置全局状态。
错误处理别用 fmt.Errorf 包一层就完事
常见写法:return fmt.Errorf("failed to create user: %w", err),看似用了 %w,但上游拿到后还是只能 if errors.Is(err, xxx) 判断,没法区分「数据库唯一键冲突」和「网络超时」。
真正有用的是自定义错误类型,配合错误码和上下文:
var (
ErrUserAlreadyExists = errors.New("user already exists")
ErrDBTimeout = errors.New("database timeout")
)
func (s *UserService) Create(ctx context.Context, u *User) error {
if _, err := s.repo.FindByEmail(ctx, u.Email); err == nil {
return fmt.Errorf("%w: email %s", ErrUserAlreadyExists, u.Email)
}
// ...
}
再配合中间件统一转 HTTP 状态码:
if errors.Is(err, ErrUserAlreadyExists) {
http.Error(w, "email taken", http.StatusConflict)
return
}
比字符串匹配更可靠,也比全用 errors.Wrap 堆栈更利于监控告警识别根因。
重构时最容易被忽略的是错误类型的粒度——不要一个服务只定义两个错误,也不用为每个 SQL 错误都建新类型,聚焦在业务语义层的失败场景即可。










