打开一个典型的后端项目,找到 Service 目录。十几个类,每个类里几个方法。仔细看方法体——大部分只有一行:把参数传给 Repository,把返回值传回 Controller。

func (s *UserService) GetUser(id string) (*User, error) {
    return s.repo.FindByID(id)
}

func (s *UserService) ListUsers() ([]*User, error) {
    return s.repo.FindAll()
}

没有判断,没有编排,没有副作用。方法签名就是 Repository 签名的翻版,唯一的区别是多占了一个文件和一层调用栈。

但这引出一个更有意思的问题:如果这些代码不是被迫写的——如果没有人要求必须建 Service 类——这些方法还会存在吗?大概率不会。它们不是因为业务需要而存在的,是因为"规范"需要。团队里所有人都知道这层没什么用,但没有人停下来问一句:Service 层到底什么时候有价值?

这个问题比看起来深。它牵涉的不是一个语言的习惯、一个框架的约定,而是"我们在代码里加一层抽象"这件事本身——什么时候加,什么时候不加,加错了的代价是什么。

Service 层从哪来

Service 层不是 Spring 发明的。

Martin Fowler 在《Patterns of Enterprise Application Architecture》里定义了 Service Layer 模式。它的原始意图很明确:在领域层和表现层之间定义一个边界,封装业务逻辑,让同一个业务操作可以被多个表现层(Web 界面、REST API、命令行工具、消息队列消费者)复用。

关键点在于:Service 层被设计出来是因为存在一个需要解决的问题——业务逻辑需要被多种接口复用。 它不是"架构的标配",是针对特定问题的一个解决方案。如果这个问题不存在(比如你的系统只有一个 REST API,不太可能再加命令行界面),Service 层的一部分原始动机就已经弱化了。

但在实践中,这个模式被普遍化了。团队把它从"解决特定问题的工具"变成了"任何后端系统的默认结构"。当一种模式从有意识的架构决策变成无意识的默认选项,退化就开始了。

退化不是一夜之间发生的。一个项目刚启动的时候,Service 层可能确实有东西——几个实质性的业务操作需要编排。然后项目往前走,加了一个新的实体,要对它做 CRUD。按照已有的模式,创建 Controller、Service、Repository 三个类。Controller 里有几行参数解析,Repository 里有 SQL 或者 ORM 调用,Service 负责在两者之间传话。

这时候 Service 已经是空气了。但没人注意到,因为每个类单独看都合理——"这个方法是查询用户,它应该在 Service 层"。合理性是分散在每个文件里的,不合理性是全局的——只有把所有 Service 方法放在一起数一遍,才能看出来一半以上是纯转发。

编排才是核心价值

跳出 CRUD 的场景,Service 层的价值到底是什么?

考虑一个下单操作。它不是一个数据库写操作。它是一个业务流程:检查库存够不够、根据定价规则算折扣、锁定用户选的优惠券、创建订单记录、扣减库存、记审计日志。六步操作涉及三种数据源(库存、订单、优惠券),每一步都有独立的失败模式,任何一步失败都需要决定是否回滚前面的步骤以及怎么回滚。

这段逻辑必须有一个明确的位置。不能放在数据库访问层——Repository 不该知道折扣怎么算、优惠券什么时候锁定。不能放在传输层——Handler 不该知道库存表的字段结构和审计日志的写入规则。它需要属于自己的地方:一个负责编排业务流程、施加业务约束、管理操作边界的层。这就是 Service 层存在的理由。

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
    stock, err := s.inventoryRepo.GetStock(ctx, req.ProductID)
    if err != nil {
        return nil, fmt.Errorf("check inventory: %w", err)
    }
    if stock.Available < req.Quantity {
        return nil, ErrInsufficientStock
    }

    discount, err := s.pricingCalc.Apply(ctx, req.ProductID, req.Quantity, req.CouponCode)
    if err != nil {
        return nil, fmt.Errorf("calculate pricing: %w", err)
    }

    if req.CouponCode != "" {
        if err := s.couponRepo.Lock(ctx, req.CouponCode, req.UserID); err != nil {
            return nil, fmt.Errorf("lock coupon: %w", err)
        }
    }

    order := &Order{
        UserID:    req.UserID,
        ProductID: req.ProductID,
        Quantity:  req.Quantity,
        Amount:    stock.UnitPrice*req.Quantity - discount,
        Status:    "pending",
    }
    if err := s.orderRepo.Create(ctx, order); err != nil {
        return nil, fmt.Errorf("create order: %w", err)
    }

    if err := s.inventoryRepo.Deduct(ctx, req.ProductID, req.Quantity); err != nil {
        return nil, fmt.Errorf("deduct inventory: %w", err)
    }

    s.auditLog.Record(ctx, "order_created", order.ID, req.UserID)
    return order, nil
}

这段代码里没有一行是在转发。删掉这个方法,系统里找不到别的地方能用更少的篇幅和更清晰的结构做到同样的事。这就是判断 Service 价值的最简标准:如果把这个方法删掉,业务逻辑会去哪?如果答案是"无处可去"或者"去了会更乱",这个方法就值它占用的那个文件。

反过来想,如果把这个方法删掉,业务逻辑哪也不会去——因为它本来就没有业务逻辑。那这个方法一开始就不该存在。

加一层的代价不止是"多了个文件"

"多一个文件,多一行转发调用,没什么大不了的"——这是让空气 Service 持续存在的最常见理由。但这个估算漏掉了真正的成本。

第一个代价是修改阻力。每加一个新的查询接口,需要在 Controller、Service、Repository 三个文件里分别加代码。三份修改中只有两份有实质内容——Service 的修改只是一个转发调用。这个阻力本身不大,但乘以接口数量再乘以时间,就是一个持续的消磨。每一次要加一个字段、改一个查询条件,都要经过这个转发层,它在开发流程中的存在就是一个恒定的微量摩擦。

更大的代价是误导。新加入的人看到这个项目的代码组织方式——Controller → Service → Repository,三层严格分层——会推测这个项目有足够的业务复杂度来支撑这套结构。但当他深入读代码,发现大部分 Service 只是转发,他会产生两种可能的结论。一种是"这个项目没那么复杂,但架构被过度设计了"。另一种——更糟的一种——是"原来 Service 层就是干这个的,以后我也这么写"。他带着这个理解去下一个项目,继续制造空气。

还有一个更微妙的问题:空气 Service 占据了命名空间。 当你真的需要一个有实质逻辑的 Service 时——比如前面那个 CreateOrder——它跟二十个转发方法待在同一个类里,或者跟一堆转发 Service 待在同一个目录里。有判断力的代码被淹没在机械的代码里,视觉上没有区别,逻辑上却在承受完全不同的预期。一个"编排业务流程"的 Service 和一个"转发数据访问"的 Service,除了都叫 Service,没有任何共同点。但命名把它们归为一类,让你误以为它们在架构上是同质的东西。

独立变更原因:一个更根本的判断标准

判断一个层该不该存在,有一个比"有没有业务逻辑"更根本的标准:这一层有没有独立于上下层的变更原因。

这是单一职责原则在架构层面的应用。Robert Martin 定义的 Common Closure Principle 说的是:一个包里的类应该对同一种变化原因封闭。倒过来也一样——如果两个东西会因为不同的原因变化,它们就不该在一个包里。往上推一层,这个原则也适用于架构分层:如果一个层会因为与上下层不同的原因变化,它有存在的理由。如果它的变化总是跟某一层完全同步,它和那一层就是同一个东西,不该分开。

看三层各自的变化驱动力:

  • 传输层(Handler / Controller):它的变化来自外界对系统的调用方式。REST 改 gRPC、JSON 改 Protobuf、一个接口拆成两个——这些都是传输层的变化。触发它们的是 API 消费者的需求,不是业务规则。
  • 数据访问层(Repository / DAO):它的变化来自数据存储的细节。换数据库、改表结构、加缓存、读写分离——这些都是数据层的变化。触发它们的是性能、成本和存储策略。
  • 业务逻辑层(Service):它的变化来自业务规则本身。"只有认证用户才能下单"、"同一用户 24 小时内最多享受一次新客优惠"、"某类商品需要额外审批"——这些都是业务规则的变化。触发它们的是产品需求、合规要求和运营策略。

三层各自有独立的变更驱动力。当 REST 改 gRPC 的时候,只有 Handler 需要改;当 PostgreSQL 换 MySQL 的时候,只有 Repository 需要改;当业务规则变了,只有 Service 需要改。这就是分层的真正价值:变化被隔离在它所属的那一层里,不扩散。

现在回头看"空气 Service":它有没有独立的变更原因?没有。一个纯转发的 getUser 方法,什么会让它变化?字段增减——那 Repository 也得改;返回格式变化——那 Handler 也得改。它的变化永远跟另一层完全同步。它在逻辑上不独立,在代码里也不该独立。

反过来的情况也一样值得警惕:当业务逻辑被塞在 Handler 或 Repository 里,它没有独立,但它被放在了错误的地方。 Handler 里的业务规则会随着传输层的变化被一起修改——本来只改一个 JSON 字段名,结果不小心碰到了折扣计算逻辑。分散在多个 Handler 里的同一个业务规则——比如"新用户首单免运费"——规则变了就得去每个 Handler 里改,而且每个 Handler 里的实现可能略有差异。

什么时候不用 Service 层

最干净的 Service 层是"没有 Service 层"——如果确实不需要。

什么情况下不需要?当接口只是对数据源的直接投影。一个查询接口,根据 ID 查一条记录返回 JSON。没有跨实体操作,没有业务规则,没有副作用,没有需要协调多个数据源的事务。这时候 Handler 直接调 Repository,什么都不会丢。增加的代码只有 Handler 里那几行参数解析和 JSON 序列化——这些本来就在 Handler 的职责范围内。

"但以后可能需要在查用户之前验权限。"

对,以后可能需要。但现在不需要。等需要的时候再加——加的时候是在已知信息下做决策:"现在需要验权限,所以需要一个 Service 方法来做权限校验 + 查询用户的编排"。而不是在未知信息下做猜测。基于已知信息加层是架构。基于猜测加层是赌——赌以后的需求方向会证明这个层的存在,赌对了就赚,赌错了就是一笔沉默成本,等下一次改动时还要再付一次。

这不是反对提前设计。是区分两件事:有些东西你知道一定会发生——比如一个电商系统的订单表一定会膨胀,订单状态一定会从三个变成七八个,业务流程一定会从简单变得复杂。对于这种确定性,提前投入架构是合理的。但"用户查询接口可能需要验权限"——你不知道什么时候需要,不知道用什么权限模型,不知道是简单的"有 token 就能查"还是复杂的基于角色的字段级别控制。在这件事上提前建一个 Service 层,不是在"做架构",是在把不确定性的成本前置。

简单 CRUD 不要表演架构体操。这不是反架构,是反在用不到的地方用架构。

Service 层在整个架构图景中的位置

如果把视野拉高一点,Service 层只是组织业务逻辑的多种方式之一。

Fowler 在 PEAA 里描述了三种组织业务逻辑的模式:Transaction Script、Domain Model、Table Module。Service 层更适合靠近 Transaction Script 的这端:一个方法对应一个业务用例,方法内部是过程的、命令式的编排。这时候 Service 就是用例的容器,每个方法告诉你"系统在当前用例下做什么操作、按什么顺序、施加什么约束"。这是最直观的业务逻辑组织方式,也是大多数业务系统实际采用的方式。

往 Domain Model 方向走,业务逻辑被分散到领域对象里,Service 层的角色会发生变化——从"用例的容器"变成"领域对象的协调者"。它不再持有业务规则本身(那些在领域对象里),而是负责把正确的领域对象拉到一起,触发它们之间的协作,管理事务边界。

往 Table Module 方向走,业务逻辑被组织在表级模块里——InvoiceModule 处理发票相关的所有操作,OrderModule 处理订单。Service 层的角色进一步弱化,业务逻辑的归属变成了"模块"而不是"层"。

关键不在于选哪一种——大多数系统不会纯粹到只用一种模式。关键在于:Service 层不是唯一的答案。它的存在和形式应该反映你的系统当前拥有的信息量和业务复杂度的形态。 如果你的业务逻辑就是一组清晰的用例,每用例涉及多表操作和业务约束——Service 层(Transaction Script 风格)是最匹配的。如果你的业务规则在实体间的交互比在用例流程里更显著——你更需要 Domain Model,Service 的存在形式会不同。如果你的业务是按数据模块组织、跨模块交互很少——你可能不需要一个单独的 Service 层,模块本身就已经是边界。

"分层架构"被当成一种通用实践,但它其实是一组具体模式在面对特定类型复杂度时的选择。不理解这个背景,分层就变成了填空——在 Controller 和 Repository 之间不管需不需要都填一个 Service 进去。

退化不是一次加坏的

一个项目的 Service 层从有用到没用,几乎不会是一次决策的结果。它是一系列各自合理的零碎决策叠加产生的。

初始版本:三个业务用例——创建订单、取消订单、退款——每个都涉及多表操作和业务约束。Service 层的三个方法是实质性的。

然后需求来了:后台需要一个用户列表查询。按照项目已有的约定,Controller → Service → Repository,三层各加代码。Service 的 listUsers() 是纯转发。单独看这一次加代码,完全合理——遵守约定,保持一致性,开发时间很短。看不出来有什么问题。

然后又一个查询需求。又一个纯转发的 Service 方法。再一个。再一个。

一年后,这个 Service 类有十二个方法,四个是实质性的业务编排,八个是纯转发。Service 层的目录里有十五个类,九个是纯转发的。没有人会在 code review 里说"这个转发方法是多余的"——因为单独看,每个转发方法都无害,而且 reviewer 不会站在全局视角审计整个目录。

这就是这类问题的阴险之处:局部最优决策的叠加可以产生全局次优结构。 每个单独的决定都合理——"按照项目约定加一个 Service 方法"从来不是一个 reviewer 会觉得需要拦住的改动。但二十个合理的改动叠加起来,可以把一个曾经有判断力的架构变成一个大部分是空气的形状。

对抗这种退化的唯一方法,不是靠 code review 时的一时判断,而是在设计层面有一个明确的、可以反复问自己的问题。不是"这样做符合规范吗",而是"这一层在这个上下文里有没有独立的变更原因"。

怎么判断

给你自己的 Service 文件做一个简单审计。数一数满足以下全部条件的方法:

  1. 方法体只有一个 repository 调用
  2. 没有调用其他 service 或 repository
  3. 没有条件判断
  4. 没有触发副作用

如果占比过半,这层就是空气。

不是说每一个都要删。有些可能很快就需要加逻辑。有些虽然现在是转发,但它在一个"确定会变复杂"的实体上——比如订单、支付、退款,你知道业务流程还在进化。判断空气与否不是看你此时此刻的代码,是看你有没有在每一次加层的时候问过"为什么"。如果你回答不上来——不是因为没想清楚,是因为压根没想过——那就不是判断的问题,是习惯的问题。

在另一个方向上,如果你的项目没有 Service 层,业务逻辑分散在 Handler 或 Repository 里,怎么判断是否需要引入?同样的逻辑反过来:打开最复杂的几个 Handler,数一数里面的业务逻辑。如果三段不同 Handler 里有相似但略有差异的库存校验逻辑,两段 Handler 里各自实现了一遍优惠券验证,四处地方在计算同一个折扣公式但参数不完全一致——那你缺的不是 Service 层这个名字,是一个能容纳这些业务规则的、有明确边界的东西。不叫 Service 也行,但它得存在。

两边的问题本质上是一样的:业务逻辑的归属是否匹配它的复杂度。 简单到不需要独立归属的,给归属就是过度。复杂到在多个地方重复的,不给归属就是散落。

一个可以带走的问题

关于 Service 层,比"要不要"更重要的,是在每一次创建新的 Service 文件、或者决定不创建的时候,能回答一个问题:

这一层有没有独立于其他层的变更原因? 如果下一个需求会让 Handler 和 Repository 都改,而 Service 也必须跟着改——那 Service 只是在两个层之间传话。如果下一个需求只改 Service——因为改的是业务规则,传输和存储都不受影响——那这一层是对的。

这个问题跟语言无关,跟框架无关,跟你用不用三层架构也无关。它检验的是一个更基本的东西:你是不是在每一个增加的抽象上,都做了一个有意识的决定。

一个没有 Service 层的系统不一定是坏的。一个满是不做决策的 Service 的系统,一定不是好的。