Handler-Service-Repository 分层
HTTP 后端将协议、业务与数据访问拆成 Handler / Service / Repository:单向依赖、可测边界,以及 DTO 与领域模型的分界。
#type / concept
#status / growing
#tech / dev / backend
#resource / go
[!info] 关联笔记
Handler-Service-Repository 分层
这个概念为什么出现
一个请求天然混合多种关注点:
- HTTP 状态码、Header、JSON
- 业务规则与事务边界
- SQL / 缓存 / 外部 API
全塞进一个 HandleFunc 时:
- 无法单测业务(必须起 HTTP)
- 换存储要改协议层
- 错误映射混乱(DB 错误直接 500 文本)
分层把变化隔离开:协议变、业务稳、存储可换。
[!abstract] 一句话理解 Handler 翻译协议;Service 实现用例;Repository 访问数据。依赖只指向内/下,不反向 import。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:GET /users/{id} 查用户资料,三层拆分
用户中心提供 GET /users/{id}:
| 层 | 本例职责 |
|---|---|
| Handler | 取路径参数、调 service、把错误映射成 404/400 |
| Service | 校验 id 非空、编排取数(可扩展权限/缓存) |
| Repository | 内存假数据(可换成 SQL) |
依赖只向下:handler → service → repo。
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
)
// 领域错误:由 service/repo 返回,handler 映射 HTTP
var errNotFound = errors.New("not found")
// User:领域/响应模型(小例合并;大项目可拆 DTO)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
// UserRepository:仓储端口——service 只依赖接口
type UserRepository interface {
Find(ctx context.Context, id string) (User, error)
}
// UserService:用例层
type UserService struct{ repo UserRepository }
func (s UserService) Get(ctx context.Context, id string) (User, error) {
// 业务校验:不碰 http.ResponseWriter
if id == "" {
return User{}, errors.New("id required")
}
return s.repo.Find(ctx, id)
}
// memRepo:Repository 实现(演示用内存;生产可 postgres)
type memRepo struct{}
func (memRepo) Find(_ context.Context, id string) (User, error) {
if id != "1" {
return User{}, errNotFound
}
return User{ID: "1", Name: "Ada"}, nil
}
// UserHandler:协议层
type UserHandler struct{ svc UserService }
func (h UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
// Go 1.22+ ServeMux 路径变量
id := r.PathValue("id")
u, err := h.svc.Get(r.Context(), id)
if err != nil {
// 错误在边界映射状态码
if errors.Is(err, errNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(u)
}
func main() {
// main 组装依赖(手动 DI)
h := UserHandler{svc: UserService{repo: memRepo{}}}
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", h.GetUser)
_ = http.ListenAndServe(":8080", mux)
}
建议运行:
go run .
# 终端 2:
curl -s http://localhost:8080/users/1
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/users/9
期望:
{"id":"1","name":"Ada"}
404
结合场景再看三个关注点
- Handler 不拼 SQL;Service 不写 HTTP 状态码。
- Repository 返回领域错误,边界再映射协议。
- 换存储只换
UserRepository实现,用例与路由可不动。
核心职责
| 层 | 职责 | 不该做 |
|---|---|---|
| Handler | 解码/编码、状态码、鉴权上下文取出、调用 service | 拼 SQL、藏业务规则 |
| Service | 用例、事务、领域校验、编排多仓储 | 依赖 http.ResponseWriter |
| Repository | 持久化细节、扫描行、缓存键 | 写 HTTP 状态码 |
依赖方向
handler → service → repository → driver
禁止 repository import handler。
DTO 与领域模型
- 请求/响应 JSON 结构可与 DB 模型分离
- Service 使用领域类型;Handler 做映射
- 避免
jsontag 泄漏进所有层(按项目纪律)
错误映射
- Service 返回领域错误(
ErrNotFound、校验错误) - Handler 映射为 404/400/409/500
- 日志在边界记录,避免每层打同一错误三遍
设计动机
与整洁架构同构但更轻:Go 不强制目录名,强制的是依赖方向与可测缝隙。见 go-project-layout-and-layering。
边界与变体
- 极简服务:两层(handler+store)也可,随复杂度长出 service。
- CQRS / 事件:service 可拆命令查询,不改分层思想。
- 事务:多表写入时事务边界通常在 service,通过
Tx或UnitOfWork传入 repo。 - Framework:Gin/Echo 只替换 handler 适配,不把业务写进框架上下文。
常见误区
[!warning] God Handler
上千行ServeHTTP含 SQL。
[!warning] Service 依赖
*gin.Context
业务与框架耦合,难测。
[!warning] 每层都定义巨大接口
接口按使用方最小集合定义。
工程实践
- 包结构示例:
internal/httpapi、internal/app、internal/store - 单测:service + fake repo;handler +
httptest - 集成测:真实 DB 测 repository
- 鉴权:中间件写入 context,handler 取出 userID 传 service
- 与 手动 DI 在 main 组装
可验证实验
- 为
UserService.Get写表驱动测试,repo 返回 not found。 - 用
httptest.NewRecorder测 handler 状态码。 - 尝试在 service 中 import
net/http并在评审中拒绝。
本节总结
自测题
- 为什么 Repository 不应返回
http status? - 事务应放在哪一层开启?
- Handler 能否直接调 Repository?
答案
- 持久化层不应知道传输协议。
- 通常在 Service(用例原子性)。
- 小项目可以,但绕过业务层易导致规则分散;有规则时走 Service。
延伸阅读
笔记元信息
- 文件:
go-http-handler-service-repository.md - 状态:已深化