Handler-Service-Repository 分层

HTTP 后端将协议、业务与数据访问拆成 Handler / Service / Repository:单向依赖、可测边界,以及 DTO 与领域模型的分界。

#type / concept #status / growing #tech / dev / backend #resource / go

[!info] 关联笔记

Handler-Service-Repository 分层

这个概念为什么出现

一个请求天然混合多种关注点:

  1. HTTP 状态码、Header、JSON
  2. 业务规则与事务边界
  3. 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

结合场景再看三个关注点

  1. Handler 不拼 SQLService 不写 HTTP 状态码
  2. Repository 返回领域错误,边界再映射协议。
  3. 换存储只换 UserRepository 实现,用例与路由可不动。

核心职责

职责不该做
Handler解码/编码、状态码、鉴权上下文取出、调用 service拼 SQL、藏业务规则
Service用例、事务、领域校验、编排多仓储依赖 http.ResponseWriter
Repository持久化细节、扫描行、缓存键写 HTTP 状态码

依赖方向

handler → service → repository → driver

禁止 repository import handler

DTO 与领域模型

  • 请求/响应 JSON 结构可与 DB 模型分离
  • Service 使用领域类型;Handler 做映射
  • 避免 json tag 泄漏进所有层(按项目纪律)

错误映射

  • Service 返回领域错误(ErrNotFound、校验错误)
  • Handler 映射为 404/400/409/500
  • 日志在边界记录,避免每层打同一错误三遍

设计动机

与整洁架构同构但更轻:Go 不强制目录名,强制的是依赖方向可测缝隙。见 go-project-layout-and-layering

边界与变体

  1. 极简服务:两层(handler+store)也可,随复杂度长出 service。
  2. CQRS / 事件:service 可拆命令查询,不改分层思想。
  3. 事务:多表写入时事务边界通常在 service,通过 TxUnitOfWork 传入 repo。
  4. Framework:Gin/Echo 只替换 handler 适配,不把业务写进框架上下文。

常见误区

[!warning] God Handler
上千行 ServeHTTP 含 SQL。

[!warning] Service 依赖 *gin.Context
业务与框架耦合,难测。

[!warning] 每层都定义巨大接口
接口按使用方最小集合定义。

工程实践

  1. 包结构示例:internal/httpapiinternal/appinternal/store
  2. 单测:service + fake repo;handler + httptest
  3. 集成测:真实 DB 测 repository
  4. 鉴权:中间件写入 context,handler 取出 userID 传 service
  5. 手动 DI 在 main 组装

可验证实验

  1. UserService.Get 写表驱动测试,repo 返回 not found。
  2. httptest.NewRecorder 测 handler 状态码。
  3. 尝试在 service 中 import net/http 并在评审中拒绝。

本节总结

  • 本质:协议 / 用例 / 数据 分离。
  • 关键:单向依赖、错误在边界映射、测试替身切入 service。
  • 下一步校验关闭、真实 SQL

自测题

  1. 为什么 Repository 不应返回 http status
  2. 事务应放在哪一层开启?
  3. Handler 能否直接调 Repository?
答案
  1. 持久化层不应知道传输协议。
  2. 通常在 Service(用例原子性)。
  3. 小项目可以,但绕过业务层易导致规则分散;有规则时走 Service。

延伸阅读


笔记元信息

  • 文件:go-http-handler-service-repository.md
  • 状态:已深化
创建于 2026/6/25 更新于 2026/7/15