分层不是为了画图好看
Go 项目写到一定规模后,总会遇到一个问题:HTTP handler 里东西越来越多。它既解析 JSON,又校验参数,又查数据库,又拼响应,还打日志。短期写得快,长期很难改。于是很多人开始学习“Clean Architecture”“DDD”“六边形架构”。这些思想有价值,但入门项目照搬全套目录,很容易过度设计。
对大多数小型 Go 服务来说,轻量分层就够了:Handler 负责 HTTP,Service 负责业务规则,Store 负责数据访问。它没有复杂术语,但能把最容易混乱的职责分开。
这篇文章用任务 API 做例子,讲怎么刚刚好地分层。
数据模型
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
Store 接口:
type TaskStore interface {
Create(ctx context.Context, title string) (Task, error)
List(ctx context.Context) ([]Task, error)
MarkDone(ctx context.Context, id int64) (Task, error)
}
Service:
type TaskService struct {
store TaskStore
}
func NewTaskService(store TaskStore) *TaskService {
return &TaskService{store: store}
}
Handler 依赖 Service:
type Handler struct {
service *TaskService
}
依赖方向很清楚:Handler 调 Service,Service 调 Store。业务规则不应该反过来依赖 HTTP。
Service 放业务规则
func (s *TaskService) Create(ctx context.Context, title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, fmt.Errorf("title is required")
}
if len([]rune(title)) > 80 {
return Task{}, fmt.Errorf("title is too long")
}
return s.store.Create(ctx, title)
}
func (s *TaskService) MarkDone(ctx context.Context, id int64) (Task, error) {
if id <= 0 {
return Task{}, fmt.Errorf("invalid task id")
}
return s.store.MarkDone(ctx, id)
}
标题不能为空、不能太长、ID 必须合法,这些都是业务或应用规则,放在 Service 比放在 Handler 更容易复用。将来如果有命令行工具或后台任务也要创建任务,可以直接调用 Service。
Handler 只处理 HTTP
type createTaskRequest struct {
Title string `json:"title"`
}
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 {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, task)
}
Handler 关心 HTTP 请求体、状态码、JSON 响应。它不直接操作数据库,也不重复业务校验。这样 handler 测试可以专注 HTTP 边界,service 测试可以专注业务规则。
Store 只处理数据
内存实现:
type MemoryTaskStore struct {
mu sync.Mutex
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}
s.tasks[task.ID] = task
s.nextID++
return task, nil
}
将来换成 SQL 实现:
type SQLTaskStore struct {
db *sql.DB
}
只要满足 TaskStore 接口,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)
}
如果它没有增加规则、事务、编排或边界,可能只是多了一层。
小结
Go 小服务的轻量分层可以很简单:Handler 处理 HTTP,Service 处理业务规则,Store 处理数据访问。依赖通过构造函数显式注入,接口定义在使用方需要的位置,层数随着复杂度增长。
分层不是为了显得专业,而是为了让代码更容易改。刚刚好的结构应该让读者更快找到规则在哪里、数据在哪里、HTTP 边界在哪里。如果分层让简单功能变得更难读,那就是过度设计。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。