跳转到正文
zeno's blog
返回

整洁架构(二):核心原则-依赖方向永远从外向内

专题: 整洁架构

Table of contents

Open Table of contents

TL;DR

Clean Architecture 只有一条铁律:内层不能 import 外层的任何东西。通过接口定义在消费方(依赖反转),实现内层调用外层能力、却不依赖外层实现。其余分层、命名都是这条规则的推论。


没有 Clean Architecture 时的典型问题

func GetUserHandler(w http.ResponseWriter, r *http.Request) {
    row := db.QueryRow("SELECT * FROM users WHERE id = $1", id)
    var u User
    row.Scan(&u.ID, &u.Name, &u.Email)
    if u.Email == "" {
        u.Email = "default@example.com"  // 业务规则散落在 handler 里
    }
    json.NewEncoder(w).Encode(u)
}

根源:所有东西耦合在一起,没有边界。

同心圆模型

┌─────────────────────────────────────────────┐
│  Frameworks & Drivers (最外层)               │
│  HTTP server, DB driver, gRPC, 第三方 SDK    │
│  ┌─────────────────────────────────────────┐ │
│  │  Interface Adapters                     │ │
│  │  Handler, Repository 实现, Presenter    │ │
│  │  ┌─────────────────────────────────────┐│ │
│  │  │  Use Cases (Application Layer)      ││ │
│  │  │  业务流程编排                         ││ │
│  │  │  ┌─────────────────────────────────┐││ │
│  │  │  │  Entities (Domain Layer)        │││ │
│  │  │  │  核心业务规则、领域模型            │││ │
│  │  │  └─────────────────────────────────┘││ │
│  │  └─────────────────────────────────────┘│ │
│  └─────────────────────────────────────────┘ │
│         依赖方向:外 → 内(永远不反过来)       │
└─────────────────────────────────────────────┘

Go 项目中的映射

myapp/
├── domain/            # Entities — 最内层,零依赖
│   ├── user.go        # 领域模型 + 业务规则
│   └── errors.go      # 领域错误定义
├── usecase/           # Use Cases — 业务流程编排
│   ├── user_service.go
│   └── port.go        # 接口定义(Repository interface 等)
├── adapter/           # Interface Adapters — 转换层
│   ├── handler/       # HTTP handler(dto ↔ domain 转换)
│   ├── repository/    # Repository 实现(domain ↔ gen 转换)
│   └── dto/           # 请求/响应结构体
├── infra/             # Frameworks & Drivers — 最外层
│   ├── db/            # 数据库连接、sqlc 生成代码
│   ├── server/        # HTTP server 启动配置
│   └── config/        # 配置加载
└── main.go            # 组装所有依赖(Dependency Injection)

每一层的职责

Domain(实体层)— 零依赖,只用标准库

package domain

type User struct {
    ID    int64
    Name  string
    Email string
}

func (u *User) Validate() error {
    if len(u.Name) < 2 {
        return errors.New("name must be at least 2 characters")
    }
    if u.Email == "" {
        return ErrEmailRequired
    }
    return nil
}

Use Case(用例层)— 定义接口,编排流程

package usecase

// Port:定义"我需要什么能力",不关心谁提供
type UserRepository interface {
    GetByID(id int64) (*domain.User, error)
    Save(user *domain.User) error
}

type UserService struct {
    repo UserRepository  // 依赖接口,不依赖实现
}

func (s *UserService) Register(name, email string) (*domain.User, error) {
    user := &domain.User{Name: name, Email: email}
    if err := user.Validate(); err != nil {
        return nil, err
    }
    if err := s.repo.Save(user); err != nil {
        return nil, err
    }
    return user, nil
}

Adapter(适配器层)— 做类型转换

// repository 实现:gen.User ↔ domain.User
func (r *PostgresUserRepo) GetByID(id int64) (*domain.User, error) {
    row, err := r.q.GetUser(context.Background(), id)
    if err != nil {
        return nil, domain.ErrUserNotFound
    }
    return &domain.User{ID: row.ID, Name: row.Name, Email: row.Email}, nil
}

// handler:domain.User ↔ dto
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)
    user, err := h.svc.Register(req.Name, req.Email)
    // ...
    json.NewEncoder(w).Encode(toUserResponse(user))
}

main.go — 组装一切(依赖注入)

func main() {
    repo := repository.NewPostgresUserRepo(gen.New(db))
    svc := usecase.NewUserService(repo)
    handler := handler.NewUserHandler(svc)
    // 路由注册...
}

依赖反转:内层怎么调用外层又不依赖外层

usecase 包定义接口:  type UserRepository interface { ... }
                            ↑ 实现
adapter 包实现接口:  type PostgresUserRepo struct { ... }

接口定义在消费方(usecase),实现在提供方(adapter)。Go 的隐式接口让这件事特别自然。

这带来的能力:

sqlc 场景下的三层类型

HTTP Request

dto.CreateUserRequest    ← API 边界:JSON tags、字段裁剪
    ↓ (mapping)
domain.User              ← 业务核心:业务规则、Go 原生类型
    ↓ (mapping)
gen.User                 ← 数据库镜像:sql.NullString 等 DB 类型

PostgreSQL

三层各自因不同原因变化:schema 迁移只影响 gen,业务规则变化只影响 domain,API 版本变化只影响 dto。mapping 样板代码是解耦的代价,直接写显式转换函数即可。

什么时候不需要

判断标准:domain model 和数据库 schema 之间有没有真正的语义差距? 没有就不要硬分。

Pitfalls

  1. 贫血模型:domain.User 只有字段没有方法,所有逻辑在 service 里 → 分层失去意义,业务规则应下沉到 entity
  2. 过度抽象:不是每个外部依赖都需要 interface,只在需要替换实现或测试隔离时才抽
  3. 接口定义在错误的地方:Go 惯例是 accept interfaces, return structs。UserRepository 定义在 usecase 包而非 repository
  4. 跨层传指针然后修改:mapping 时创建新对象,不要把 gen.User 指针传到 handler 再改字段

分享这篇文章:

上一篇
mio(四):为什么不直接用 mio
下一篇
mio(三):平台后端-epoll、kqueue、IOCP