← [ BACK_TO_NOTES ]

Handler-Service-Repository 分层

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

#concept #growing #backend #go

[!info] related notes

Handler-Service-Repository 分层

这个概念为什么出现

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

  1. HTTP 状态码、Header、JSON
  2. 业务规则与事务边界
  3. SQL / 缓存 / 外部 API

全塞进一个 HandleFunc 时:

  • 业务测试被绑在协议处理上;虽可用 httptest 直接调用,但难以单独测试用例
  • 换存储要改协议层
  • 错误映射混乱(DB 错误直接 500 文本)

分层把变化隔离开:协议变、业务稳、存储可换。

[!abstract] 一句话理解 Handler 翻译协议;Service 实现用例;Repository 访问数据。运行时调用沿 Handler → Service → Repository;源码依赖可通过接口倒置,Service 无需 import 具体数据库实现。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:GET /users/{id} 查用户资料,三层拆分

用户中心提供 GET /users/{id}:

层本例职责
Handler取路径参数、调 service、把错误映射成 404/400/500
Service校验 id 非空、编排取数(可扩展权限/缓存)
Repository内存假数据(可换成 SQL)

本例把所有类型放在一个 main 包里演示协作;实际拆包时,Service 持有消费方定义的仓储接口,由 main 注入具体实现。需要 Go 1.22+。

package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"net/http"
)

// 领域错误:由 service/repo 返回,handler 映射 HTTP
var errNotFound = errors.New("not found")
var errInvalidID = errors.New("id required")

// 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{}, errInvalidID
	}
	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 {
		// 错误在边界映射状态码
		switch {
		case errors.Is(err, errNotFound):
			http.Error(w, "not found", http.StatusNotFound)
		case errors.Is(err, errInvalidID):
			http.Error(w, "bad request", http.StatusBadRequest)
		default:
			http.Error(w, "internal error", http.StatusInternalServerError)
		}
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_ = 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)
	log.Fatal(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

此例只演示只读内存仓储,不需要锁,也没有真实 DB/事务。替换实现时需保持错误、取消和并发合同;不能仅凭接口签名假定不同存储语义等价。

结合场景再看三个关注点

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

核心职责

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

依赖方向

handler → service → repository → driver

上图表示运行时调用顺序。采用依赖倒置时,具体 store 实现满足 app 定义的接口,main 同时依赖双方来组装;app 不必 import store。避免底层 import handler,也避免接口签名暴露 SQL 驱动类型导致反向耦合。

DTO 与领域模型

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

错误映射

  • Service 返回领域错误(ErrNotFound、校验错误)
  • Handler 按明确错误类型映射 404/400/409,未分类内部失败返回通用 500;不要把 DB 故障误记为客户端 400
  • 日志在边界记录,避免每层打同一错误三遍

设计动机

与整洁架构同构但更轻:Go 语言不强制这些层或目录名;项目通过设计和评审维护依赖方向与可测试边界。见 go-project-layout-and-layering。

边界与变体

  1. 极简服务:两层(handler+store)也可,随复杂度长出 service。
  2. CQRS / 事件:service 可拆命令查询,不改分层思想。
  3. 事务:用例决定原子性范围;仓储操作必须共享同一事务,不能各自提交。可用事务回调/端口在存储适配层管理 Tx,避免为简单操作先造通用 UnitOfWork。
  4. Framework:Gin/Echo 只替换 handler 适配,不把业务写进框架上下文。

常见误区

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

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

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

工程实践

  1. 包结构示例:internal/httpapi、internal/app、internal/store
  2. 单测:service + fake repo;handler + httptest
  3. 集成测:真实 DB 测 repository
  4. 认证主体由中间件提供;Service 按主体与资源执行授权,不能把「有 userID」等同于有访问权限
  5. 与 手动 DI 在 main 组装

可验证实验

  1. 为 UserService.Get 写表驱动测试,repo 返回 not found。
  2. 用 httptest.NewRecorder 测 handler 状态码。
  3. 检查 Service 是否依赖入站 HTTP Writer/Request;调用外部 HTTP 服务可以藏在客户端端口后,不能仅按 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/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本