跳转到正文
zeno's blog
返回

整洁架构(三):方法设计-每层用自己的语言命名和传参

专题: 整洁架构

Table of contents

Open Table of contents

TL;DR

Handler 说协议语言(HTTP 动词 + 资源),Service 说业务语言(领域动词 + 意图),Repository 说存储语言(CRUD + 查询条件),Domain Entity 说规则语言(不变量 + 状态转换)。如果两层的方法签名几乎一样,说明有一层在当透传中间人,不该存在。


四层方法职责总览

请求流向:Handler → Service → Domain Entity + Repository

                              依赖方向:外层依赖内层
方法回答的问题命名风格参数类型返回值
Handler”这个端点干什么”协议动词框架类型(Request/Context)框架类型(Response/error)
Service”这个业务操作叫什么”领域动词proto message / 独立参数 / Command(按场景选)proto response / 领域模型
Domain Entity”这个实体的规则是什么”状态转换动词值对象或原语error(违反规则时)
Repository”怎么存取这个实体”存储动词领域模型 + 查询条件领域模型

Handler 层:说协议的语言

职责

协议翻译。把 HTTP/gRPC 的请求解码成 Service 能理解的输入,把 Service 的输出编码成协议响应。零业务逻辑。

方法命名

跟路由/RPC 方法对齐,名字体现”这是哪个端点”:

// 标准库风格 — 动词跟 HTTP method 对齐
func (h *UserHandler) HandleRegister(w http.ResponseWriter, r *http.Request)
func (h *UserHandler) HandleGetProfile(w http.ResponseWriter, r *http.Request)
func (h *UserHandler) HandleChangePassword(w http.ResponseWriter, r *http.Request)

// Echo/Gin 风格 — 方法名就是业务动词,框架提供路由上下文
func (h *UserHandler) Register(c echo.Context) error
func (h *UserHandler) GetProfile(c echo.Context) error
func (h *UserHandler) ChangePassword(c echo.Context) error

// gRPC — 方法名由 proto 定义决定
func (h *UserServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error)

参数与返回值

Handler 的参数和返回值必须是框架/协议类型,不能泄露领域模型到 API 签名:

func (h *UserHandler) Register(c echo.Context) error {
    // 1. 解码:协议类型 → DTO
    var req RegisterRequest
    if err := c.Bind(&req); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
    }

    // 2. 调用 Service:DTO → 领域参数
    user, err := h.svc.Register(c.Request().Context(), usecase.RegisterCmd{
        Email:    req.Email,
        Password: req.Password,
        Name:     req.DisplayName,   // 字段名可能不同
    })

    // 3. 错误翻译:领域错误 → HTTP 状态码
    if err != nil {
        return h.mapError(err)  // ErrEmailTaken → 409, ErrWeakPassword → 422
    }

    // 4. 编码:领域模型 → 响应 DTO
    return c.JSON(http.StatusCreated, toUserResponse(user))
}

gRPC + grpc-gateway:Handler 层消失了

当使用 gRPC + grpc-gateway 时,Handler 和 Service 实质上合并了——proto service 定义同时扮演了路由和业务入口:

HTTP JSON ──grpc-gateway──→ proto message ──→ Service 方法
gRPC client ─────────────→ proto message ──→ Service 方法

不需要单独的 Handler 层做 DTO 转换,proto message 同时是传输格式和 Service 输入。此时上面 HTTP Handler 的四步(解码 → 调 Service → 错误翻译 → 编码)全部由 grpc-gateway + gRPC interceptor 处理。

Handler 里绝对不能出现的东西

// ❌ 业务逻辑
if req.Role == "admin" && !currentUser.IsSuperAdmin() {
    return echo.NewHTTPError(403, "insufficient permissions")
}

// ❌ 直接操作数据库
db.Exec("INSERT INTO users ...")

// ❌ 跨服务调用
emailClient.SendWelcomeEmail(user.Email)

// ❌ 多个 Service 调用的编排
user, _ := h.userSvc.Create(...)
h.notifySvc.SendWelcome(user.ID)
h.auditSvc.LogCreation(user.ID)
// 这三步应该在一个 Service 方法里

Service 层:说业务的语言

职责

业务流程编排。协调领域模型和 Repository 完成一个完整的业务用例。一个 Service 方法 = 一个业务用例 = 通常一个事务边界。

方法命名:领域动词,不是 CRUD

这是最重要的命名原则——Service 方法名必须让不懂代码的产品经理也能读懂

// ❌ CRUD 命名 — 这些方法名没有业务语义
func (s *UserService) CreateUser(ctx context.Context, user *User) error
func (s *UserService) UpdateUser(ctx context.Context, user *User) error
func (s *UserService) DeleteUser(ctx context.Context, id string) error

// ✅ 领域动词命名(gRPC 风格)— 方法名 = proto RPC 名 = 业务操作名
func (s *UserService) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error)
func (s *UserService) Authenticate(ctx context.Context, req *pb.AuthenticateRequest) (*pb.AuthenticateResponse, error)
func (s *UserService) ChangePassword(ctx context.Context, req *pb.ChangePasswordRequest) (*pb.ChangePasswordResponse, error)
func (s *UserService) DeactivateAccount(ctx context.Context, req *pb.DeactivateAccountRequest) (*pb.DeactivateAccountResponse, error)

// ✅ 领域动词命名(纯 HTTP 风格)— 参数是独立值或 Command
func (s *UserService) Register(ctx context.Context, email, password, name string) (*User, error)
func (s *UserService) Authenticate(ctx context.Context, email, password string) (*AuthResult, error)
func (s *UserService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd string) error

一个典型的坏味道:一个 UpdateUser 方法承担了改密码、改角色、改头像、改邮箱等多个业务操作。这些操作的前置条件、权限要求、副作用完全不同,不应共用一个方法。

参数设计:三种实际做法

Go 微服务的 Service 方法参数没有唯一正确的方式,取决于项目上下文。以下按大厂实际采用频率排序:

做法一:proto message 直传(gRPC 服务的主流做法)

字节 Kitex、腾讯 trpc-go、B站 Kratos 生态下,proto message 本身就是类型安全的、有版本管理的输入结构。再封一层 Command struct 是纯样板代码:

// 大多数 gRPC 微服务的实际写法
func (s *UserService) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
    // 直接用 req.Email, req.Password
}

适用场景: 服务只有一个入口(gRPC),proto 定义就是契约。大多数微服务属于这种情况。

做法二:独立参数(HTTP 服务 / 参数少时)

// 参数 ≤ 3 个且语义明确 → 直接传,简单清晰
func (s *UserService) GetProfile(ctx context.Context, userID string) (*UserProfile, error)
func (s *UserService) Authenticate(ctx context.Context, email, password string) (*AuthResult, error)
func (s *UserService) DeactivateAccount(ctx context.Context, userID string) error

适用场景: 纯 HTTP 服务、参数少、不需要频繁扩展。

做法三:Command/Query 对象(多入口或复杂输入时)

当 Service 有多个入口(HTTP + gRPC + MQ consumer + CLI),且各入口的输入形态不同时,Command struct 作为 Service 层的稳定契约才有价值:

// Service 被 HTTP handler、gRPC handler、MQ consumer 三个入口调用
// 每个入口的输入类型不同,但 Service 只认 RegisterCmd
func (s *UserService) Register(ctx context.Context, cmd RegisterCmd) (*User, error)

type RegisterCmd struct {
    Email    string
    Password string
    Name     string
}

如果你的 Service 只有一个入口(大多数微服务的现实),这层抽象就是过度设计。

无论哪种做法,这条原则不变

不要用一个”万能 Update struct”承载多个业务操作:

// ❌ Patch 风格的 God struct — 掩盖业务意图
type UpdateUserCmd struct {
    ID     string
    Name   *string   // 调用方不知道哪些组合是合法的
    Email  *string
    Role   *string
    Avatar *string
}

// ✅ 按业务操作拆开 — 不管用哪种参数风格
func (s *UserService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd string) error
func (s *UserService) PromoteToAdmin(ctx context.Context, userID, promotedBy string) error

关于 CQRS

完整的 CQRS(Command Query Responsibility Segregation,读写模型分离)是独立的架构模式,在读写负载严重不对称时才值得引入(比如电商商品详情页用 Elasticsearch 做读模型)。不要把”Service 方法参数用 Command struct”等同于”实施了 CQRS”——前者只是参数传递方式的选择,后者是架构级决策。

Proto 定义与 Service 方法的对齐关系

gRPC 服务中,proto service 定义本身就应该按业务操作设计,Service 直接实现这些 RPC:

// proto 定义就是 Service 层的公开契约
service UserService {
  rpc Register(RegisterRequest) returns (RegisterResponse);
  rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
  rpc ChangePassword(ChangePasswordRequest) returns (ChangePasswordResponse);
  rpc DeactivateAccount(DeactivateAccountRequest) returns (DeactivateAccountResponse);
}

proto RPC 名 = Service 方法名 = 业务操作名。没有翻译层。

但 Service 可能有 proto 里没有的内部方法——被 MQ consumer、定时任务、或其他模块进程内调用:

// proto 里有 — 对外公开的业务操作
func (s *UserService) Register(ctx, req) (*pb.RegisterResponse, error)

// proto 里没有 — 内部方法,不对外暴露
func (s *UserService) CleanupExpiredSessions(ctx context.Context) error
func (s *UserService) HandleUserRegisteredEvent(ctx context.Context, event UserRegistered) error

准确的关系是 proto RPC 集合 ⊆ Service 方法集合

判断 proto 粒度是否正确的标准: 如果需要组合调用多个 RPC 才能完成一个业务操作,说明 proto 切太细了,应该定义一个更粗粒度的 RPC。反过来,一个 RPC 内部拆成多个步骤实现是正常的。

Proto message 的字段设计同样要遵守按业务操作拆分的原则:

// ❌ 万能更新 — 调用方不知道哪些字段组合是合法的
message UpdateUserRequest {
  string user_id = 1;
  optional string name = 2;
  optional string email = 3;
  optional string role = 4;
}

// ✅ 按业务操作各自定义 message
message ChangePasswordRequest {
  string user_id = 1;
  string old_password = 2;
  string new_password = 3;
}

message ChangeEmailRequest {
  string user_id = 1;
  string new_email = 2;
}

返回值设计

gRPC 服务: 直接返回 proto response。proto message 同时是领域模型的传输表示,不需要额外的 domain struct 再做转换(除非领域模型和 proto 之间有真正的语义差距,比如有不该暴露的内部状态):

func (s *UserService) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error)
func (s *UserService) Authenticate(ctx context.Context, req *pb.AuthenticateRequest) (*pb.AuthenticateResponse, error)

纯 HTTP 服务: 返回领域模型或业务结果类型,由 Handler 转 DTO:

// 创建/变更操作 → 返回操作后的实体
func (s *UserService) Register(ctx context.Context, email, password, name string) (*User, error)

// 复合结果 → 专用结果类型
func (s *UserService) Authenticate(ctx context.Context, email, password string) (*AuthResult, error)

type AuthResult struct {
    User         *User
    AccessToken  string
    RefreshToken string
    ExpiresAt    time.Time
}

// 纯副作用 → 只返回 error
func (s *UserService) DeactivateAccount(ctx context.Context, userID string) error

Service 方法的内部结构

一个 Service 方法的典型流程(这是判断方法是否属于 Service 层的标准):

func (s *UserService) Register(ctx context.Context, cmd RegisterCmd) (*User, error) {
    // 1. 前置校验(跨实体的业务规则)
    exists, err := s.repo.ExistsByEmail(ctx, cmd.Email)
    if err != nil {
        return nil, fmt.Errorf("check email existence: %w", err)
    }
    if exists {
        return nil, ErrEmailAlreadyRegistered
    }

    // 2. 构建/操作领域模型
    hashed, err := s.hasher.Hash(cmd.Password)
    if err != nil {
        return nil, fmt.Errorf("hash password: %w", err)
    }
    user, err := NewUser(cmd.Email, hashed, cmd.Name)  // 工厂方法包含验证
    if err != nil {
        return nil, err
    }

    // 3. 持久化
    if err := s.repo.Save(ctx, user); err != nil {
        return nil, fmt.Errorf("save user: %w", err)
    }

    // 4. 副作用(事件、通知等)
    s.events.Publish(ctx, UserRegistered{UserID: user.ID, Email: user.Email})

    return user, nil
}

如果一个 Service 方法只有第 3 步(调 repo),这个方法不该存在——Handler 应该直接调 Repository。

Domain Entity 层:说规则的语言

职责

封装单个实体的业务不变量。实体方法保证对象在任何时刻都处于合法状态。

方法命名:状态转换动词

type User struct {
    id        string     // 小写:外部通过方法访问,不能绕过规则直接改字段
    email     string
    name      string
    role      Role
    status    Status
    password  string     // hashed
    createdAt time.Time
}

// 工厂方法 — 创建时就强制合法
func NewUser(email, hashedPassword, name string) (*User, error) {
    if !isValidEmail(email) {
        return nil, ErrInvalidEmail
    }
    if name == "" {
        return nil, ErrNameRequired
    }
    return &User{
        id:        generateID(),
        email:     email,
        name:      name,
        password:  hashedPassword,
        role:      RoleViewer,    // 新用户默认角色
        status:    StatusActive,
        createdAt: time.Now(),
    }, nil
}

// 状态转换方法 — 名字体现"从什么变成什么"
func (u *User) Deactivate() error {
    if u.status == StatusDeactivated {
        return ErrAlreadyDeactivated
    }
    u.status = StatusDeactivated
    return nil
}

func (u *User) Promote(newRole Role) error {
    if u.status != StatusActive {
        return ErrInactiveUserCannotBePromoted
    }
    if newRole.Level() <= u.role.Level() {
        return ErrCannotDemoteViaPromote
    }
    u.role = newRole
    return nil
}

// 查询方法 — 返回派生信息,不改变状态
func (u *User) IsActive() bool    { return u.status == StatusActive }
func (u *User) CanModerate() bool { return u.role.Level() >= RoleModerator.Level() }

Service vs Entity 方法的边界

特征Entity 方法Service 方法
涉及几个实体只操作自身协调多个实体
需要 Repository 吗不需要需要
需要外部服务吗不需要可能需要
有副作用吗无(只改自身状态)有(持久化、发事件)
需要 context 吗不需要需要(传播取消/超时)
// ✅ 属于 Entity:只检查自身状态
func (u *User) CanChangeEmail() bool {
    return u.status == StatusActive && u.emailVerified
}

// ✅ 属于 Service:需要查数据库、需要事务
func (s *UserService) ChangeEmail(ctx context.Context, userID, newEmail string) error {
    user, err := s.repo.GetByID(ctx, userID)
    if err != nil { return err }

    if !user.CanChangeEmail() {     // 先问 Entity
        return ErrCannotChangeEmail
    }

    taken, err := s.repo.ExistsByEmail(ctx, newEmail)
    if err != nil { return err }
    if taken { return ErrEmailAlreadyRegistered }

    user.SetEmail(newEmail)         // Entity 执行状态变更
    return s.repo.Save(ctx, user)   // Service 负责持久化
}

Repository 层:说存储的语言

职责

领域模型与存储介质之间的映射。 对内(Service 层)暴露的是领域模型接口;对外(数据库)操作的是存储特定的类型。

接口定义(在 Service/Use Case 层)

// 定义在消费方(依赖反转)
type UserRepository interface {
    Save(ctx context.Context, user *User) error
    GetByID(ctx context.Context, id string) (*User, error)
    ExistsByEmail(ctx context.Context, email string) (bool, error)
    FindByStatus(ctx context.Context, status Status, page Pagination) ([]*User, error)
}

命名规则

// ✅ 标准命名模式
Save(ctx, entity)           // 创建或更新(upsert 语义)
GetByID(ctx, id)            // 按主键查,找不到返回 error
GetByXxx(ctx, xxx)          // 按唯一键查
FindByXxx(ctx, xxx)         // 按条件查,返回列表(可能为空)
ExistsByXxx(ctx, xxx)       // 存在性检查
Delete(ctx, id)             // 删除
Count(ctx, filter)          // 计数

// ❌ 不好的命名
InsertUser(...)             // "Insert" 是 SQL 词汇,不是领域词汇
SelectUserByEmail(...)      // "Select" 泄露了 SQL 实现
UpdateUserRole(...)         // 细粒度 update 是 Repository 知道太多业务细节的信号

参数与返回值:只用领域类型

// ✅ 参数和返回值都是领域类型
func (r *PostgresUserRepo) Save(ctx context.Context, user *domain.User) error {
    // 内部做 domain → gen (sqlc) 的转换
    _, err := r.q.UpsertUser(ctx, gen.UpsertUserParams{
        ID:       user.ID(),
        Email:    user.Email(),
        Name:     user.Name(),
        Role:     string(user.Role()),
        Status:   string(user.Status()),
        Password: user.HashedPassword(),
    })
    return err
}

func (r *PostgresUserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
    row, err := r.q.GetUserByID(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, domain.ErrUserNotFound  // 转换成领域错误
        }
        return nil, fmt.Errorf("query user: %w", err)
    }
    // gen → domain 的转换
    return domain.ReconstructUser(row.ID, row.Email, row.Name, domain.Role(row.Role), ...), nil
}

Repository 不该做的事

// ❌ Repository 里出现业务判断
func (r *PostgresUserRepo) SaveIfEmailNotTaken(ctx context.Context, user *domain.User) error {
    // "email 是否已注册" 是业务规则,不是存储关注点
}

// ❌ Repository 返回 DTO 或 proto 类型
func (r *PostgresUserRepo) GetByID(ctx context.Context, id string) (*pb.UserResponse, error) {
    // Repository 只跟领域模型打交道
}

// ❌ Repository 做数据聚合
func (r *PostgresUserRepo) GetUserWithOrders(ctx context.Context, id string) (*UserWithOrders, error) {
    // 跨聚合的查询应该在 Service 层编排,或用专门的 ReadModel/Query Service
}

错误设计:每层说自己的错误语言

// Domain 层:业务规则错误
var (
    ErrInvalidEmail     = errors.New("invalid email format")
    ErrNameRequired     = errors.New("name is required")
    ErrAlreadyDeactivated = errors.New("user already deactivated")
)

// Service 层:业务流程错误(可以 wrap domain error)
var (
    ErrEmailAlreadyRegistered = errors.New("email already registered")
    ErrInvalidCredentials     = errors.New("invalid email or password")
    ErrAccountSuspended       = errors.New("account has been suspended")
)

// Handler 层:翻译成协议错误
func (h *UserHandler) mapError(err error) *echo.HTTPError {
    switch {
    case errors.Is(err, usecase.ErrEmailAlreadyRegistered):
        return echo.NewHTTPError(http.StatusConflict, err.Error())
    case errors.Is(err, usecase.ErrInvalidCredentials):
        return echo.NewHTTPError(http.StatusUnauthorized, err.Error())
    case errors.Is(err, domain.ErrInvalidEmail), errors.Is(err, domain.ErrNameRequired):
        return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
    default:
        return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
    }
}

// Repository 层:把存储错误翻译成领域错误
// sql.ErrNoRows → domain.ErrUserNotFound
// unique constraint violation → 返回原始 error 让 Service 处理

自检清单:你的分层是否在制造价值

症状诊断处方
Service 方法名和 Handler 方法名一模一样没找到领域语言用领域动词重命名 Service 方法
Service 方法只有一行 return s.repo.Xxx()Service 层在当透传中间人删掉 Service 方法,Handler 直接调 Repository
一个 UpdateUser 方法处理所有更新场景业务意图被 CRUD 掩盖拆成 ChangePassword、ChangeEmail、Promote 等
Repository 有 UpdateUserRole 这种细粒度方法Repository 知道了太多业务统一用 Save 做全量更新
Handler 里有 if-else 业务判断业务逻辑泄露到协议层下沉到 Service 或 Entity
Entity struct 字段全部 public 且没有方法贫血模型,分层失去意义把业务规则从 Service 下沉到 Entity 方法
Service 方法参数超过 5 个缺少 Command 对象提取 Command struct
每加一个字段要改 4 层分层粒度太细或这个服务不需要这么多层合并层,或反思是否过度架构
完成一个业务操作需要组合调用多个 RPCproto 粒度切太细定义更粗粒度的 RPC 表达完整业务意图
proto message 和 domain struct 字段一模一样多了一层无意义的转换gRPC 服务直接用 proto message,省掉 domain struct

Pitfalls

  1. Service 命名用 CRUD 是最常见的架构退化起点。一旦叫 CreateUser,所有调用方都会把它当”创建一行数据”来理解,而不是”执行注册这个业务操作”。命名影响认知,认知影响后续设计
  2. 不要为了 “架构正确” 强加 Command 对象。gRPC 服务里 proto message 已经是类型安全的输入契约,再封一层 Command struct 纯增加维护负担。只有当 Service 有多个不同形态的入口时,独立 Command 才有价值
  3. 不要给 Repository 加业务语义的方法名FindActiveUsersCreatedAfter 这种方法意味着 Repository 在理解什么叫”active”——应该用 FindByStatus + 参数来保持 Repository 的无知
  4. context.Context 必须是 Service/Repository 方法的第一个参数。Domain Entity 方法不需要 context(它们不做 I/O)。如果你的 Entity 方法需要 context,说明它在做不该做的事
  5. 纯 HTTP 服务中,Service 返回领域模型不返回 DTO,由 Handler 负责 domain → DTO 转换。但 gRPC 服务中 proto message 同时承担了领域传输和 API 契约的角色,Service 直接返回 proto response 是务实的做法——不要为了”架构纯洁”在 proto 和 domain 之间加一层字段一模一样的转换

分享这篇文章:

上一篇
mio(五):关键设计决策
下一篇
mio(四):为什么不直接用 mio