跳到内容

第 6 卷 / 第 11 章 / 第 1 课

11.1 分层不是分文件夹:职责、依赖与事务边界 ​

一个 1500 行的单体模块可以同时处理 HTTP、业务规则和 SQL,但任何一种变化都会穿过全部代码。分层的目标是隔离变化原因并约束依赖方向,不是多建三个目录就宣布胜利。

把 controller、service、repository 建成三个目录,只完成了命名;只有变化被隔离、依赖方向受约束、事务边界有归属时,分层才算成立。

本课解决三个问题:每层负责什么,数据如何穿过边界,一次业务操作应该在哪里开始和结束。

先按变化原因划分 ​

一个典型业务应用可以分成四类职责:

层负责不应负责
展示层HTTP、CLI 或 UI 输入输出;协议校验;身份上下文业务规则、SQL、跨步骤事务
应用层编排一个用例;划定事务;调用领域对象和端口HTTP 细节、ORM 映射、核心业务判定
领域层业务状态、规则、不变式和决策框架生命周期、网络与数据库协议
基础设施层数据库、消息、文件和第三方系统的技术实现决定业务政策

这不是说每个项目必须有四层。简单 CRUD 可以合并应用层和领域层;规则复杂时,单独的领域层能避免业务政策散落在控制器、SQL 和消息消费者中。

关键判断不是“这个类叫什么”,而是“什么变化会迫使它修改”。HTTP 状态码变化不该改领域对象;数据库驱动升级不该改注册资格规则。

一个请求如何穿过边界 ​

以“玩家报名比赛”为例:

text
POST /tournaments/{id}/entries
        │  HTTP → 命令
        ▼
EntryController                 展示层
        │
        ▼
RegisterEntry                   应用层:事务与调用顺序
        ├── TournamentRepository.findById(id)  加载
        ├── Tournament.register(playerId)     领域决策
        └── TournamentRepository.save(...)    保存
             ↑ 端口由基础设施适配器实现

图中是应用层掌握的调用顺序,领域对象不会向下调用具体仓储。展示层把协议数据转成命令,应用层加载对象、调用领域规则,再通过端口保存。

下面是 Java 21 / Spring Framework 6.2 项目片段,每个公开类型单独存放。项目需提供领域模型、TournamentRepository、TournamentNotFound 和 EntryAuthorizer。后者依据服务器认证上下文检查是否允许当前操作者为指定玩家报名;请求里的 playerId 只是目标,不是身份证明。

java
public record RegisterEntryCommand(
        java.util.UUID tournamentId, java.util.UUID playerId) {
    public RegisterEntryCommand {
        java.util.Objects.requireNonNull(tournamentId, "tournamentId");
        java.util.Objects.requireNonNull(playerId, "playerId");
    }
}

@org.springframework.stereotype.Service
public class RegisterEntry {
    private final TournamentRepository tournaments;

    private final EntryAuthorizer authorizer;

    public RegisterEntry(TournamentRepository tournaments, EntryAuthorizer authorizer) {
        this.tournaments = java.util.Objects.requireNonNull(tournaments);
        this.authorizer = java.util.Objects.requireNonNull(authorizer);
    }

    @org.springframework.transaction.annotation.Transactional
    public void handle(RegisterEntryCommand command) {
        authorizer.requireCanRegister(command.playerId(), command.tournamentId());
        Tournament tournament = tournaments.findById(command.tournamentId())
            .orElseThrow(TournamentNotFound::new);

        tournament.register(command.playerId());
        tournaments.save(tournament);
    }
}

RegisterEntry 不返回 ResponseEntity,输入也不是 ORM 实体。这里有意识地让应用层依赖 Spring 事务注解;若要保持核心不依赖框架,可在外层事务装饰器中包住用例,下一章会讨论这种边界。

服务由 Spring 管理,启用事务管理并配置匹配的事务管理器和仓储适配器后,经代理调用才会开启事务。这个类不设为 final,因为示例没有接口,需要可继承的类代理;直接 new 或默认代理模式下的同类自调用不会因为注解自动生效。Spring 事务注解说明 详述了这些条件。

java
import java.util.UUID;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

record RegisterEntryRequest(@NotNull UUID playerId) {}

@RestController
final class EntryController {
    private final RegisterEntry registerEntry;

    EntryController(RegisterEntry registerEntry) {
        this.registerEntry = java.util.Objects.requireNonNull(registerEntry);
    }

    @PostMapping("/tournaments/{id}/entries")
    ResponseEntity<Void> register(
        @PathVariable("id") UUID id,
        @Valid @RequestBody RegisterEntryRequest body
    ) {
        registerEntry.handle(new RegisterEntryCommand(id, body.playerId()));
        return ResponseEntity.noContent().build();
    }
}

控制器需要 Spring MVC、JSON 转换器以及 Jakarta Bean Validation 实现。@Valid 配合 @NotNull 拒绝缺失的玩家 ID;应用命令还自行校验空值,保护非 HTTP 入口。成功返回 204,无响应体。

缺失字段属于协议错误,赛事已截止属于业务拒绝。前者由 Spring 的参数验证处理为 400,后者以及不存在、权限拒绝等结果需要项目的异常处理器映射;没有展示处理器,就不能声称这段代码已实现完整的 HTTP 错误契约。

事务边界属于用例 ​

事务通常应该包住一次原子业务操作,而不是包住整个 Web 请求,也不是分散到每个仓储方法。

如果报名需要同时:

  1. 检查比赛状态;
  2. 写入报名记录;
  3. 占用一个名额;

那么应用服务应让这些数据库操作处于同一事务。但“同一事务”不等于“不会超卖”:两个事务仍可能同时读到最后一个名额,需要唯一约束、带版本的更新或锁来守住具体不变式。Spring 默认回滚未检查异常和 Error,检查异常需按业务配置回滚规则,别把异常吞掉后期待事务猜中你的意思。若还要发送外部消息,不要把不可靠的网络调用假装成数据库事务的一部分;可以在同一事务写入 Outbox,再异步发布。第 15 章会展开这个问题。

边界上的数据模型 ​

同一个概念在不同边界可以有不同表示:

  • RegisterEntryRequest 是传输模型,服务于 JSON 兼容性;
  • RegisterEntryCommand 是用例输入,服务于应用语义;
  • Tournament 是领域模型,维护不变式;
  • TournamentRow 是持久化模型,服务于表结构。

小系统可以有意识地复用某些类型,但不能把复用当成默认。把 ORM 实体直接序列化给客户端,会让数据库结构、延迟加载和公开 API 一起耦合。

常见的伪分层 ​

控制器越过应用层 ​

控制器直接访问仓储,会把业务顺序和事务含义带回协议层。以后增加消息入口或批处理入口,只能复制这段编排。

Service 只是转发器 ​

若每个 service 方法都只调用同名 repository 方法,新增一层没有隔离任何变化。可以合并它,或者让应用服务围绕实际用例命名。

按技术名词分包 ​

全局的 controllers/、services/、repositories/ 会让一个业务能力散落在整个仓库。规模增长后,优先按业务模块组织,再在模块内部划分层:

text
tournament/
├── api/
├── application/
├── domain/
└── infrastructure/

只允许调用相邻层 ​

“必须逐层转发”不是目的。需要保护的是职责和依赖方向。展示层调用应用层、应用层使用领域层是常见做法;不必为了形式制造没有语义的代理方法。

完成标准 ​

完成一个用例设计时,应能回答:

  • 谁把外部协议翻译成用例输入?
  • 谁维护业务不变式?
  • 事务从哪里开始、在哪里结束?
  • 数据库或框架类型是否越过了不该越过的边界?
  • 同一用例换成消息入口后,哪些代码可以原样复用?

下一课把镜头缩到展示层,区分 MVC、MVP 与 MVVM。它们解决的是交互结构问题,不是整个后端系统的分层方案。

参考资料 ​

Built with VitePress | Software Systems Atlas