HTTP handler 的唯一职责是解析请求、校验参数、调 service 方法、返回响应;业务判断逻辑(如 if user.Balance)应移至 service 层,避免 handler 耦合业务规则。

handler 里别写业务判断逻辑
HTTP handler 的唯一职责是解析请求、校验参数、调 service 方法、返回响应。一旦你在 user_handler.go 里写 if user.Balance 或拼接邮件内容,就等于把领域规则硬编码进传输层,后续改规则就得改 handler,测试也必须走 HTTP 层。
正确做法是把判断逻辑下沉到 UserService 方法中,比如 CanWithdraw(),handler 只负责传参和转发错误:
func (h *UserHandler) Withdraw(c *gin.Context) {
var req WithdrawRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, ErrorResponse{Message: "invalid request"})
return
}
err := h.userService.Withdraw(req.UserID, req.Amount)
if err != nil {
c.JSON(403, ErrorResponse{Message: err.Error()})
return
}
c.JSON(200, SuccessResponse{})
}
- 所有校验用
validator标签在WithdrawRequest结构体上声明,不手写 if -
Withdraw()返回具体错误类型(如ErrInsufficientBalance),而非泛型error,便于 handler 做差异化响应 - handler 不 import 数据库、邮件 SDK、缓存 client 等任何 infra 包
service 方法必须能脱离 HTTP 独立测试
一个 UserService 方法如果依赖 *gin.Context、http.Request 或直接调 log.Printf,它就不是纯业务逻辑——你无法在 TestWithdraw 里只给两个 int 就跑通,还得 mock router、构造 fake request。
真正可测的 service 方法签名应像这样:
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
func (s *UserService) Withdraw(userID int, amount float64) error
- 输入是原始值或 domain struct(如
User),输出是 error 或结构体(如Transaction) - 内部调用的 repo 方法也必须是 interface,例如
s.userRepo.DecreaseBalance(userID, amount) - 事务控制放在 service 层,用
repo.WithTx()包裹多个 repo 调用,而不是在 handler 里开事务 - 第三方调用(发短信、扣款网关)必须抽象为 interface,测试时用 mock 实现替换
用表格驱动测试覆盖多分支业务路径
Withdraw 涉及余额不足、冻结账户、超单日限额、风控拦截等多个失败分支,手工写一堆 TestWithdraw_XXX 函数容易漏 case,也难维护。表格驱动测试能一目了然看到所有路径是否被覆盖。
示例结构:
func TestUserService_Withdraw(t *testing.T) {
type args struct {
userID int
amount float64
}
tests := []struct {
name string
args args
setup func(*mocks.MockUserRepo, *mocks.MockRiskService)
wantErr bool
}{
{"insufficient balance", args{1, 200}, func(r *mocks.MockUserRepo, s *mocks.MockRiskService) {
r.EXPECT().GetBalance(1).Return(100.0, nil)
}, true},
{"frozen user", args{2, 50}, func(r *mocks.MockUserRepo, s *mocks.MockRiskService) {
r.EXPECT().IsFrozen(2).Return(true, nil)
}, true},
{"ok", args{3, 30}, func(r *mocks.MockUserRepo, s *mocks.MockRiskService) {
r.EXPECT().GetBalance(3).Return(100.0, nil)
r.EXPECT().DecreaseBalance(3, 30).Return(nil)
}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockUserRepo(ctrl)
mockRisk := mocks.NewMockRiskService(ctrl)
tt.setup(mockRepo, mockRisk)
s := &UserService{userRepo: mockRepo, riskService: mockRisk}
if err := s.Withdraw(tt.args.userID, tt.args.amount); (err != nil) != tt.wantErr {
t.Errorf("Withdraw() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
- 每个 test case 显式声明前置条件(
setup)、输入、预期结果 - mock 对象在每个子测试中重建,避免状态污染
- 错误类型断言比字符串匹配更可靠,比如
errors.Is(err, ErrFrozenAccount)
事件驱动不适合替代同步业务流程
有人看到“用户提现成功后发短信”就立刻用 event bus 把 WithdrawSuccessEvent publish 出去,结果发现:短信没发、余额已扣、事务已提交,但 event handler panic 了——没法回滚。
核心原则:**影响主流程一致性的操作必须同步执行;通知类、审计类、耗时类才异步解耦。**
- 提现成功 → 扣余额 → 更新流水 → 记录审计日志 → 发送短信(异步)→ 推送 App(异步)
- event bus 的监听器必须幂等,且不能修改任何 shared state(比如不能在 handler 里再调
userRepo.DecreaseBalance) - 关键事件(如支付成功)建议加 DB 写入 + 延迟队列重试,别只靠内存 channel
- 若 handler 需要 context 超时控制,event bus 的
Publish(ctx, e)必须支持,并在每个 listener 中检查ctx.Err()
最易被忽略的是事件 payload 的稳定性:UserRegisteredEvent 里加了个新字段,旧版邮件 handler 就可能 panic。要么用嵌套结构预留扩展字段,要么在事件头里加 Version 字段做路由分发。


















