用 struct 定义领域实体(User/Order/Product),interface 抽象业务能力(如 OrderService),handler 仅做协议转换,错误用自定义 error,依赖通过构造函数注入,测试聚焦跨组件流程而非单个方法。

用 struct + interface 搭建可测试的业务骨架
Go 初级项目最容易堆逻辑进 main 或 handler,结果一加需求就改到崩溃。真实业务建模第一步不是写数据库或 HTTP,而是把「谁在什么条件下做什么」用 Go 类型表达清楚。
比如「用户下单」这个动作,拆成:User、Order、Product 三个 struct,再定义 OrderService interface 描述它该提供什么能力(CreateOrder(ctx, userID, items)),而不是直接实现。
- struct 字段名用业务语义命名(如
Order.Status而非order_status_int),避免后期靠注释猜含义 - interface 方法参数尽量传值不传指针,除非明确需要修改原对象;返回错误统一用
error,不裸露fmt.Errorf字符串 - 别急着加 ORM 标签(如
gorm:"column:xxx")——先让 domain 层干净,infra 层再做映射
HTTP handler 只做协议转换,不掺业务判断
新手常把权限校验、库存扣减、发消息全塞进 http.HandlerFunc,导致无法单独测库存逻辑,也无法复用到 CLI 或 gRPC 入口。
正确做法是 handler 只干三件事:解析请求(json.Unmarshal)、调用 service 方法、序列化响应。所有 if/else 分支都应下沉到 service 层。
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
order, err := h.orderService.CreateOrder(r.Context(), req.UserID, req.Items)
if err != nil {
switch {
case errors.Is(err, ErrInsufficientStock):
http.Error(w, "out of stock", http.StatusPreconditionFailed)
default:
http.Error(w, "internal error", http.StatusInternalServerError)
}
return
}
json.NewEncoder(w).Encode(map[string]string{"id": order.ID})
}
- 错误类型用自定义 error(如
var ErrInsufficientStock = errors.New("insufficient stock")),不用字符串匹配 - handler 不持有 DB 连接或 Redis 客户端——这些通过 service 构造函数注入
- 如果要支持多种输入(CLI / gRPC),只需新增一个入口函数,复用同一套
orderService
用 testify/mock 验证业务流程而非单个函数
初级项目测试常陷入「给每个 struct 方法写单元测试」的误区,但真实问题出在流程断点:比如「扣库存成功但发消息失败,订单状态卡在 pending」。
应该用 testify/mock 或纯 interface stub 模拟依赖,专注验证跨组件协作。例如模拟 InventoryClient 返回失败,看 OrderService.CreateOrder 是否回滚并返回对应错误。
- mock 的重点是「控制依赖行为」,不是「覆盖所有分支」——只 mock 那些影响流程走向的返回值(如
stock ) - 测试用例名体现业务场景:
TestOrderService_CreateOrder_WhenStockIsZero_ReturnsInsufficientStockError - 避免在测试里构造真实 DB 或 Redis——用内存 map 或
sqlmock替代
config 和 env 初始化必须在 main 包最顶层完成
很多人把 viper.SetConfigFile 放在某个 service 初始化函数里,结果测试时 config 加载时机错乱,或者不同包读到不同配置值。
所有配置加载、依赖初始化(DB、Redis、Logger)必须在 main() 开头一次性做完,然后以结构体字段方式注入到各 service 中。
func main() {
cfg := loadConfig()
logger := newLogger(cfg.LogLevel)
db := newDB(cfg.DBURL)
redis := newRedis(cfg.RedisAddr)
orderService := NewOrderService(
WithInventoryClient(NewInventoryClient(redis)),
WithPaymentClient(NewPaymentClient(cfg.PaymentAPI)),
WithLogger(logger),
WithDB(db),
)
http.ListenAndServe(":8080", NewRouter(orderService))
}
- 不要在 service 构造函数里调
viper.GetString——配置应作为参数传入,便于测试替换 - env 变量名保持与 config key 一致(如
DB_URL→cfg.DBURL),避免拼写差异引发线上事故 - 如果项目变大,把 config 结构体拆成
DatabaseConfig、CacheConfig等子结构,比扁平 map 更易维护
业务建模最难的不是语法,是克制——忍住不早早在 struct 里加 CreatedAt 字段,不急着在 handler 里写日志,先让「用户下单」这件事在代码里能被清晰读出来、被独立测试、被安全替换掉依赖。剩下都是体力活。










