Go 轻量分层入门:Handler、Service、Store 怎么刚刚好

本文用任务 API 示例讲解 Go 项目中轻量分层架构的设计方法,说明 Handler、Service、Store 的职责边界,附带接口测试和避免过度设计的最佳实践。

分层不是为了画图好看

Go 项目写到一定规模后,总会遇到一个问题:HTTP handler 里东西越来越多。它既解析 JSON,又校验参数,又查数据库,又拼响应,还打日志。短期写得快,长期很难改。于是很多人开始学习 Clean Architecture、DDD、六边形架构。这些思想有价值,但入门项目照搬全套目录,很容易过度设计。

对大多数小型 Go 服务来说,轻量分层就够了:Handler 负责 HTTP,Service 负责业务规则,Store 负责数据访问。它没有复杂术语,但能把最容易混乱的职责分开。

这篇文章用任务 API 做例子,覆盖 CRUD、错误处理、测试和常见陷阱。目标是给你一个可以跑起来的结构,而不是只能看看的伪代码。

项目结构

一个合理的小型 Go 项目目录:

taskapi/
├── main.go          // 依赖组装、路由注册、服务启动
├── handler.go       // HTTP handler
├── service.go       // 业务逻辑
├── store.go         // Store 接口 + 内存实现
├── model.go         // 数据模型
└── response.go      // 统一响应格式

文件不多,职责清楚。随着项目变大,可以按领域拆分到 internal/task/、internal/user/ 等包,但起步阶段不用急着拆分。

数据模型

package main

type Task struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
}

type CreateTaskRequest struct {
	Title string `json:"title"`
}

type UpdateTaskRequest struct {
	Title *string `json:"title,omitempty"`
	Done  *bool   `json:"done,omitempty"`
}

CreateTaskRequest 和 UpdateTaskRequest 是 DTO(数据传输对象),用于解耦 HTTP 请求体与内部模型。使用指针类型表示可选字段,区分零值和缺失。

Store 接口

type TaskStore interface {
	Create(ctx context.Context, title string) (Task, error)
	GetByID(ctx context.Context, id int64) (Task, error)
	List(ctx context.Context) ([]Task, error)
	Update(ctx context.Context, id int64, req UpdateTaskRequest) (Task, error)
	Delete(ctx context.Context, id int64) error
}

接口定义在使用方(Service)的包中,而不是实现方。这是 Go 的惯用做法:依赖倒置通过接口声明位置体现,不需要像 Java 那样单独建 interface 包。

Service 放业务规则

type TaskService struct {
	store TaskStore
}

func NewTaskService(store TaskStore) *TaskService {
	return &TaskService{store: store}
}

var (
	ErrTaskNotFound = errors.New("task not found")
	ErrInvalidID    = errors.New("invalid task id")
	ErrTitleEmpty   = errors.New("title is required")
	ErrTitleTooLong = errors.New("title is too long")
)

func (s *TaskService) Create(ctx context.Context, title string) (Task, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return Task{}, ErrTitleEmpty
	}
	if len([]rune(title)) > 200 {
		return Task{}, ErrTitleTooLong
	}
	return s.store.Create(ctx, title)
}

func (s *TaskService) MarkDone(ctx context.Context, id int64) (Task, error) {
	if id <= 0 {
		return Task{}, ErrInvalidID
	}
	task, err := s.store.GetByID(ctx, id)
	if err != nil {
		return Task{}, err
	}
	if task.Done {
		return task, nil
	}
	return s.store.Update(ctx, id, UpdateTaskRequest{Done: boolPtr(true)})
}

标题不能为空、不能太长、ID 必须合法,这些都是业务规则,放在 Service 比放在 Handler 更容易复用。将来如果有命令行工具或后台任务也要创建任务,可以直接调用 Service。

预定义错误变量(ErrTaskNotFound 等)让调用方能用 errors.Is 做精确判断,而不是字符串匹配。

Handler 只处理 HTTP

type Handler struct {
	service *TaskService
}

func NewHandler(service *TaskService) *Handler {
	return &Handler{service: service}
}

func (h *Handler) CreateTask(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid json")
		return
	}

	task, err := h.service.Create(r.Context(), req.Title)
	if err != nil {
		switch {
		case errors.Is(err, ErrTitleEmpty), errors.Is(err, ErrTitleTooLong):
			writeError(w, http.StatusBadRequest, err.Error())
		default:
			writeError(w, http.StatusInternalServerError, "internal error")
		}
		return
	}

	writeJSON(w, http.StatusCreated, task)
}

func (h *Handler) GetTask(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "invalid id")
		return
	}

	task, err := h.service.store.GetByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, ErrTaskNotFound) {
			writeError(w, http.StatusNotFound, "task not found")
			return
		}
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}

	writeJSON(w, http.StatusOK, task)
}

Handler 关心 HTTP 请求体、状态码、JSON 响应。它不直接操作数据库,也不重复业务校验。这样 handler 测试可以专注 HTTP 边界,service 测试可以专注业务规则。

这里把 GetTask 的 store 调用直接暴露出来不太妥当,更好的做法是在 Service 层也提供一个 GetByID 方法,保持层间一致性。这个例子里的写法是为了说明常见的初学者做法以及正确的改进方向。

统一响应格式

统一响应让前端和 API 消费方更容易处理:

type Response struct {
	Data  any    `json:"data,omitempty"`
	Error string `json:"error,omitempty"`
}

func writeJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(Response{Data: data})
}

func writeError(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(Response{Error: msg})
}

统一响应格式在团队开发和 SDK 封装中价值很大。错误统一放在 error 字段,成功时放在 data 字段,前端不需要根据状态码猜测响应结构。

Store 只处理数据

内存实现:

type MemoryTaskStore struct {
	mu     sync.RWMutex
	nextID int64
	tasks  map[int64]Task
}

func NewMemoryTaskStore() *MemoryTaskStore {
	return &MemoryTaskStore{
		nextID: 1,
		tasks:  make(map[int64]Task),
	}
}

func (s *MemoryTaskStore) Create(ctx context.Context, title string) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	task := Task{
		ID:        s.nextID,
		Title:     title,
		CreatedAt: time.Now(),
	}
	s.tasks[task.ID] = task
	s.nextID++
	return task, nil
}

func (s *MemoryTaskStore) GetByID(ctx context.Context, id int64) (Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	task, ok := s.tasks[id]
	if !ok {
		return Task{}, ErrTaskNotFound
	}
	return task, nil
}

func (s *MemoryTaskStore) List(ctx context.Context) ([]Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	items := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		items = append(items, t)
	}
	// 按 ID 排序保证输出稳定
	slices.SortFunc(items, func(a, b Task) int {
		return int(a.ID - b.ID)
	})
	return items, nil
}

func (s *MemoryTaskStore) Update(ctx context.Context, id int64, req UpdateTaskRequest) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	task, ok := s.tasks[id]
	if !ok {
		return Task{}, ErrTaskNotFound
	}
	if req.Title != nil {
		task.Title = *req.Title
	}
	if req.Done != nil {
		task.Done = *req.Done
	}
	s.tasks[id] = task
	return task, nil
}

func (s *MemoryTaskStore) Delete(ctx context.Context, id int64) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.tasks[id]; !ok {
		return ErrTaskNotFound
	}
	delete(s.tasks, id)
	return nil
}

内存实现使用 sync.RWMutex 保证并发安全,读操作使用读锁提高并发度。List 方法返回排序后的结果,保证测试输出稳定可预期。

将来换成 SQL 实现:

type SQLTaskStore struct {
	db *sql.DB
}

func NewSQLTaskStore(db *sql.DB) *SQLTaskStore {
	return &SQLTaskStore{db: db}
}

只要满足 TaskStore 接口,Service 不需要知道底层是内存还是数据库。SQL 实现中,Update 可以使用 COALESCE 处理可选字段,避免条件拼接 SQL。

依赖组装与路由注册

main.go 把所有依赖组装起来:

func main() {
	store := NewMemoryTaskStore()
	service := NewTaskService(store)
	handler := NewHandler(service)

	mux := http.NewServeMux()
	mux.HandleFunc("POST /tasks", handler.CreateTask)
	mux.HandleFunc("GET /tasks/{id}", handler.GetTask)
	mux.HandleFunc("GET /tasks", handler.ListTasks)
	mux.HandleFunc("PATCH /tasks/{id}", handler.UpdateTask)
	mux.HandleFunc("DELETE /tasks/{id}", handler.DeleteTask)

	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}

	slog.Info("server starting", "addr", server.Addr)
	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		slog.Error("server error", "err", err)
	}
}

这是 Go 中最简单的依赖注入:构造函数传参。不需要框架,每个依赖从外部传入,测试时可以轻松替换。对比 Java Spring 的自动装配,Go 的做法显式但简单直接。

优雅关闭

生产环境需要处理优雅关闭,在收到终止信号后给正在进行的请求处理时间:

func main() {
	store := NewMemoryTaskStore()
	service := NewTaskService(store)
	handler := NewHandler(service)

	mux := http.NewServeMux()
	mux.HandleFunc("POST /tasks", handler.CreateTask)
	mux.HandleFunc("GET /tasks", handler.ListTasks)

	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}

	go func() {
		slog.Info("server starting", "addr", server.Addr)
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			slog.Error("server error", "err", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := server.Shutdown(ctx); err != nil {
		slog.Error("server shutdown error", "err", err)
	}
	slog.Info("server stopped")
}

server.Shutdown 会等待已有连接处理完毕,或超时后强制关闭。5 秒超时对大多数 API 请求足够。如果业务中有长时间运行的请求,需要调整超时时间。

测试策略

轻量分层让测试也变得清晰。Handler 测试用 httptest:

func TestCreateTask(t *testing.T) {
	store := NewMemoryTaskStore()
	service := NewTaskService(store)
	h := NewHandler(service)

	body := bytes.NewReader([]byte(`{"title":"learn Go"}`))
	req := httptest.NewRequest("POST", "/tasks", body)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()

	h.CreateTask(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("status = %d, want 201", w.Code)
	}

	var resp Response
	if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
		t.Fatal(err)
	}

	task, ok := resp.Data.(map[string]any)
	if !ok {
		t.Fatal("expected data to be a task map")
	}
	if task["title"] != "learn Go" {
		t.Fatalf("title = %v, want 'learn Go'", task["title"])
	}
}

Service 测试直接调用方法:

func TestServiceCreateValidation(t *testing.T) {
	store := NewMemoryTaskStore()
	svc := NewTaskService(store)

	_, err := svc.Create(context.Background(), "")
	if !errors.Is(err, ErrTitleEmpty) {
		t.Fatalf("want ErrTitleEmpty, got %v", err)
	}

	_, err = svc.Create(context.Background(), strings.Repeat("x", 201))
	if !errors.Is(err, ErrTitleTooLong) {
		t.Fatalf("want ErrTitleTooLong, got %v", err)
	}
}

Store 测试可以验证 CRUD 操作:

func TestStoreCRUD(t *testing.T) {
	store := NewMemoryTaskStore()
	ctx := context.Background()

	task, err := store.Create(ctx, "test")
	if err != nil {
		t.Fatal(err)
	}

	got, err := store.GetByID(ctx, task.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.Title != task.Title {
		t.Fatalf("title = %s, want %s", got.Title, task.Title)
	}

	if err := store.Delete(ctx, task.ID); err != nil {
		t.Fatal(err)
	}

	_, err = store.GetByID(ctx, task.ID)
	if !errors.Is(err, ErrTaskNotFound) {
		t.Fatal("expected task not found after delete")
	}
}

三层测试不互相依赖。改 Service 逻辑不需要改 Handler 测试;换 Store 实现不影响 Service 测试。这是分层的核心价值之一。

什么时候不要继续拆

不要一开始就拆出 domain、usecase、repository、dto、assembler、factory、manager 一大堆层。层数越多,简单需求跳转越累。

一个实用标准:如果一层没有独立职责,只是在传参,那就先不要拆。比如:

func (u *CreateTaskUsecase) Execute(ctx context.Context, title string) (Task, error) {
	return u.service.Create(ctx, title)
}

如果它没有增加规则、事务、编排或边界,可能只是多了一层。

另一个信号:当你需要修改一个功能时,要改超过 3 个文件才能看到全部代码,就要审视是否有不必要的拆分。好的结构应该让修改集中在一到两个文件中。

常见错误与排查

错误:Handler 里直接调用数据库

func (h *Handler) CreateTask(w http.ResponseWriter, r *http.Request) {
	// 错误:直接操作 db
	h.db.Exec("INSERT INTO tasks ...")
}

这样 Service 层完全失去存在的意义。数据库操作应该放在 Store 层。

错误:Service 返回具体错误信息给客户端

writeError(w, http.StatusBadRequest, err.Error()) // 可能暴露内部细节

内部错误不应该直接返回给客户端。应该映射到统一错误响应。

错误:Store 层做业务校验

Store 层的职责是存取数据。不为空、长度限制等应该在 Service 层做。Store 层可以信任传入数据是已经校验过的。

错误:没有 context 透传

func (s *TaskService) Create(title string) (Task, error) {
	return s.store.Create(context.Background(), title) // 错误,应该由调用方传入
}

context 应该从 HTTP handler 传入,经过 Service 传到 Store,用于取消信号和链路追踪。

接口定义位置的讨论

在 Go 中,接口定义在哪里一直有两种做法:

  1. 消费者定义(本文做法):TaskStore 定义在 Service 包中,Store 实现适配这个接口
  2. 生产者定义:TaskStore 定义在 Store 包中,Service 依赖这个接口

推荐消费者定义。因为接口的契约应该由使用方决定。TaskService 需要什么方法,就定义什么接口。不同 Service 对 Store 的需求可能不同,不需要强迫同一个接口。

当项目变大后,如果需要多个 Service 复用同一个 Store 接口,可以把接口提升到更上游的包中,比如 internal/store/store.go。

更多实战 FAQ

Q: HTTP handler 里的 context 数据怎么传给 Service?
A: *http.Request.Context() 已经是 context,直接传。不要在 Service 中读 request header 或 form value。

Q: 多个 Store 方法需要事务怎么办?
A: 在 SQL 场景下,可以在 Store 层暴露事务接口:

type TaskStore interface {
	Create(ctx context.Context, tx *sql.Tx, title string) (Task, error)
}

或者更优雅的做法是使用 Unit of Work 模式,在 Service 层控制事务边界。轻量项目中也可以让 Store 方法内部自主管理事务。

Q: 要不要用 wire 或 fx 做依赖注入?
A: 对小型项目来说,手写构造函数足够。当依赖关系变复杂(10+ 个依赖)时,可以考虑 google/wire 代码生成。不要用反射型 DI 框架,它们会增加运行时开销和调试难度。

Q: 怎么区分 Domain Error 和 Infrastructure Error?
A: 简单做法是:Service 层返回定义好的 sentinel error(如 ErrTaskNotFound);Store 层返回原始错误。Handler 判断 errors.Is(err, ErrTaskNotFound) 返回 404,其他未知错误返回 500。

Q: 内存 Store 在并发场景安全吗?
A: 需要使用 sync.RWMutex 或 sync.Mutex 保护读写。本文的内存实现中已经包含了锁保护。如果是单元测试单 goroutine 执行,理论上不需要锁,但加上锁没有坏处。

Q: 字段很多时 DTO 转换怎么避免重复?
A: 简单的项目直接手写转换函数。字段很多时可以用代码生成工具(如 deepcopy、mapstructure)。不要把所有字段都塞进 DTO,只传输必要的字段。

从简单开始,按需扩展

分层的最终目标是让代码更容易理解和修改。以下是一个实用演进路径:

  1. 起步:全部代码写在一个包里,Handler 调 Service,Service 调 Store
  2. 增长:按领域拆分到 internal/task/、internal/user/ 等包
  3. 规模:引入接口包(internal/store),统一存储接口定义
  4. 大型:考虑引入 DDD 的聚合根、领域事件等概念

不要在第 1 步就引入第 4 步的复杂度。每个阶段的复杂度应该和项目规模匹配。

小结

Go 小服务的轻量分层可以很简单:Handler 处理 HTTP,Service 处理业务规则,Store 处理数据访问。依赖通过构造函数显式注入,接口定义在使用方需要的位置,层数随着复杂度增长。

分层不是为了显得专业,而是为了让代码更容易改。刚刚好的结构应该让读者更快找到规则在哪里、数据在哪里、HTTP 边界在哪里。如果分层让简单功能变得更难读,那就是过度设计。

记住几个关键原则:context 始终透传、错误分层映射、测试解耦独立、复杂度与规模匹配。当你遇到一个功能需要改很多文件才能理解全貌时,就是审视结构是否合理的时机。好的分层让新项目快速启动,也让老项目长期可维护。

接口测试策略

分层后测试策略变得更清晰:

func TestTaskServiceCreate(t *testing.T) {
    store := NewMemoryTaskStore()
    svc := NewTaskService(store)
    
    task, err := svc.Create(context.Background(), "learn Go")
    if err != nil {
        t.Fatal(err)
    }
    if task.Title != "learn Go" {
        t.Fatalf("title = %q", task.Title)
    }
}

Service 层的测试不需要启动 HTTP 服务器,也不需要真实数据库。内存 store + 接口就能完成。这种测试运行速度快、稳定性高、编写成本低。

Handler 层的测试专注 HTTP 边界:

func TestCreateTaskHandler(t *testing.T) {
    store := NewMemoryTaskStore()
    h := NewHandler(NewTaskService(store))
    
    body := strings.NewReader(`{"title":"test"}`)
    req := httptest.NewRequest(http.MethodPost, "/tasks", body)
    rec := httptest.NewRecorder()
    
    h.createTask(rec, req)
    
    if rec.Code != http.StatusCreated {
        t.Fatalf("status = %d", rec.Code)
    }
}

依赖注入的方式选择

除了构造函数注入,Go 中还有几种常见的依赖注入方式:

  1. 构造函数注入(推荐):清晰、编译器检查、测试方便
  2. 全局变量:简单但难以测试,容易隐藏依赖
  3. 方法参数注入:适合单次使用,但会使方法签名冗长
  4. Context 注入:不推荐,context 不适合传递业务依赖

构造函数注入是 Go 社区的主流选择,它让依赖关系一目了然。

接口放在哪里

Go 推荐"消费者定义接口",而不是生产者定义。比如 TaskStore 接口应该放在 Service 包中(使用方),而不是 Store 包中(实现方)。这样:

  • Service 只依赖它需要的最小接口
  • 新增 Store 实现不需要修改接口定义
  • 避免循环依赖
// 在 service 包中定义
package service

type TaskStore interface {
    Create(ctx context.Context, title string) (Task, error)
    List(ctx context.Context) ([]Task, error)
}

避免贫血模型

轻量分层不代表所有业务逻辑都堆在 Service 中。Domain 模型(如 Task)可以有自己的方法:

type Task struct {
    ID    int64
    Title string
    Done  bool
}

func (t Task) CanComplete() bool {
    return !t.Done
}

把不依赖外部系统的规则放在模型上,既减少 Service 体积,也让规则更容易测试。

常见反模式

God Service:一个 Service 处理所有业务,几百行代码。解决:按领域拆分为多个 Service。

透传层:每层只是转调下一层,不做任何有意义的工作。解决:层数不够就减少,不要为了分层而分层。

** premature abstraction**:一个 CRUD 服务过早抽接口、工厂、策略模式。解决:先用具体类型,真的有多个实现时再抽象。

实战 FAQ

Q: 需要 DTO 吗?
A: 简单项目可以直接用 Domain 结构体。当 API 结构体和存储结构体差异较大时再用 DTO 转换。

Q: 事务放在哪一层?
A: Store 层提供事务能力,Service 层编排多个 Store 操作的事务边界。不要跨层传播事务。

Q: 日志该放在哪一层?
A: 粗粒度日志(如请求日志)放 Handler;业务日志放 Service;调试日志根据需要放。不要在每一层都重复记录。

小结

Go 小服务的轻量分层可以很简单:Handler 处理 HTTP,Service 处理业务规则,Store 处理数据访问。依赖通过构造函数显式注入,接口定义在使用方需要的位置,层数随着复杂度增长。

分层不是为了显得专业,而是为了让代码更容易改。刚刚好的结构应该让读者更快找到规则在哪里、数据在哪里、HTTP 边界在哪里。如果分层让简单功能变得更难读,那就是过度设计。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练