分层图人人会画,难的是边界上那些「顺手就能做」的事:Controller 直接注入 Repository、Entity 直接当响应体、事务注解加在私有方法上。这篇按一个文章管理接口的演进过程,说明四层各自拥有什么、不该知道什么,以及这些边界在 Spring Boot 3 里具体怎么落。示例基于 Spring Boot 3.3 与 Java 21,是独立编写的参考实现,不是本仓库代码。

1. 先看一个「能跑」的 Controller

我见过最多的写法,是在 Controller 里注入 ArticleRepository,方法签名直接收 Article 实体,判空、拼 slug、save 一气呵成。功能上线没问题,但它同时踩了四个坑,而且互相掩盖:

  • 入参和出参都是 JPA 实体,等于把表结构直接变成 API 契约。加一个 @OneToMany 关联,响应里就会多出一个字段,甚至触发懒加载异常。
  • 校验靠手写 if,@Valid 和 jakarta validation 完全没参与,注解和实际行为对不上。
  • 业务规则(slug 生成、唯一性、状态默认值)散落在 Controller,第二个入口(定时发布、导入)必须复制一遍。
  • 没有事务边界,后续加「写文章 + 写版本快照」时,两次写不在同一个事务里。

单独修其中任何一个都不解决问题,因为根因是「层与层之间没有约定」。

2. 分层不是四个包,是四条依赖规则

把代码放进 controller/、service/、repository/ 四个目录,只是分层的外观。真正的分层由四条规则定义:

  1. 依赖单向向内:Controller 依赖 Service,Service 依赖 Repository,Repository 依赖 JPA。反向依赖一律不允许,Repository 里出现 HttpServletRequest 就是信号。
  2. 不跳层:Controller 不碰 Repository,Repository 不感知 Service。跳层省下的那点代码,会在加缓存、加审计、加事务时加倍还回来。
  3. 形状在边界转换:Entity 不跨出 Service,DTO 不进入 Repository。每一层只认自己那一层的数据形状。
  4. 事务只在 Service 声明:Repository 的方法不该自带 @Transactional,否则「一个用例一个事务」这个前提就没了。

第 3、4 条最容易被违反,因为 Spring 让它们「看起来能跑」。

贫血模型还是领域模型

这是分层讨论里最容易跑偏的地方。两种极端都不可取:

  • 纯贫血:Entity 只有 getter / setter,所有规则塞进 Service。规则一多,Service 变成上千行的事务脚本,改一个字段要全文搜索。
  • 纯充血:每个字段都包一个值对象,每个动作都进实体,Repository 只负责 save。对以 CRUD 为主的博客后台,这是纯成本。

我的取舍标准是不变量:一条规则如果需要跨多个字段、多次写、或者有明确的状态迁移,就放进实体;如果只是编排(查用户、查分类、拼装、发事件),就留在 Service。文章的状态机(草稿 → 发布 → 下线)适合放进实体,因为它有前置条件;「创建文章时检查 slug 是否重复」适合留在 Service,因为它要访问 Repository。

写成一句话:实体保护自己的不变量,Service 编排用例,事务跟着用例走。这不是领域驱动设计的完整方法论,只是一个能在三五人团队里稳定执行的中间态。真正要警惕的是两头都不占:规则一部分在实体、一部分在 Service、一部分在 Controller,最后没人说得清一条业务约束到底在哪。

3. DTO 与 Entity 必须分离

先看实体。它承载持久化细节:主键生成策略、乐观锁版本、审计时间、懒加载关联。这些没有一个是客户端应该看到的。

@Entity
@Getter
public class Article {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false) private String title;
    @Column(nullable = false) private String slug;
    @Lob @Column(nullable = false) private String content;
    @Enumerated(EnumType.STRING) private ArticleStatus status = ArticleStatus.DRAFT;

    public void publish() {
        if (status == ArticleStatus.PUBLISHED) throw new ConflictException("already_published");
        this.status = ArticleStatus.PUBLISHED;
    }
}

protected 无参构造、Article.draft(...) 静态工厂、@Version 与审计字段在这里为篇幅省略。

对应的 DTO 用 record,不可变、少样板,Jackson 从 2.12 起原生支持:

public record CreateArticleRequest(
        @NotBlank @Size(max = 200) String title,
        @NotBlank @Size(max = 200) @Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$") String slug,
        @NotBlank String content) {
}

public record ArticleResponse(Long id, String title, String slug, String status, Instant updatedAt) {
}

几个细节值得强调:

  • record 的校验注解写在组件上,编译器会同时落到字段与访问器;但 Controller 参数必须加 @Valid,否则注解只是装饰。
  • CreateArticleRequest 故意没有 status、id、version。客户端不能决定主键,也不能直接把文章标成已发布——那是另一个用例。
  • 响应 DTO 里不要放关联实体,放 categoryId、List<Long> tagIds 这类扁平值。一旦返回 Category,就等于承诺了分类对象的结构。

4. MapStruct:映射留在编译期

最容易偷懒的写法是 BeanUtils.copyProperties(entity, dto)。它靠反射、字段名错了不报错、类型不匹配也静默跳过,重构时几乎没有保护。MapStruct 在编译期生成映射代码,字段对不上直接编译失败。

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface ArticleMapper {
    ArticleResponse toResponse(Article article);
}

unmappedTargetPolicy = ERROR 是关键配置:实体新增字段而映射没跟上时,构建直接失败,而不是悄悄返回 null。

反方向(DTO → Entity)我故意没让 MapStruct 生成。实体有工厂方法和不变量,生成器只会调用 setter 或全参构造,绕过校验。入站用手写工厂,出站用生成映射,这条不对称是有意的。

5. Controller:只做协议转换与校验

Controller 的职责可以压缩成三件事:把 HTTP 输入变成方法参数、触发校验、把结果写回 HTTP 语义。它不应该出现 if (status == ...) 这样的业务分支。

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/articles")
public class ArticleController {
    private final ArticleService articleService;
    private final ArticleMapper articleMapper;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ArticleResponse create(@Valid @RequestBody CreateArticleRequest request) {
        return articleMapper.toResponse(articleService.create(request));
    }
}

Service 里的 publish 是独立用例,对外我映射成 POST /{id}/publish,而不是 PATCH /{id} 改 status 字段。严格 REST 会说这是动词路径,但状态迁移带前置条件,做成显式动作更好审计,也避免客户端把 status 改成任意值。这是取舍,不是教条。

Controller 里不要写 try/catch。捕获后返回 ResponseEntity.badRequest() 会让异常处理散落在每个方法里,统一交给下一节的 @RestControllerAdvice。

6. Service:用例边界就是事务边界

Service 的方法名应该是用例名,不是表名。publish 是一个用例,updateArticleStatus 只是字段操作。

@Service
@RequiredArgsConstructor
public class ArticleService {
    private final ArticleRepository articleRepository;

    @Transactional
    public Article create(CreateArticleRequest request) {
        if (articleRepository.existsBySlug(request.slug())) throw new ConflictException("article.slug_taken");
        return articleRepository.save(Article.draft(request.title(), request.slug(), request.content()));
    }

    @Transactional
    public Article publish(Long id) {
        Article article = articleRepository.findById(id)
                .orElseThrow(() -> new NotFoundException("article.not_found"));
        article.publish();
        return article;   // 脏检查自动 flush
    }
}

两个容易忽略的点:

  • create 里「先查重再插入」在并发下仍可能插入两条,唯一索引才是最终保障。Service 的检查是为了给出友好错误,不是为了正确性。
  • publish 不调用 save,靠脏检查在事务提交时 flush。这要求方法在事务内、实体处于持久态。一旦有人把 @Transactional 挪走,代码仍然编译通过,但更新会静默丢失——这是分层里最隐蔽的一类 bug。

事务边界还有一条硬约束:不要在事务内做远程调用。发邮件、调第三方、上传 OSS 都应该在事务提交之后,否则数据库连接被占用的时间等于网络抖动的时间。把这类动作挪到 @TransactionalEventListener 的 AFTER_COMMIT 阶段,是成本最低的修法。

7. Repository:不要把 JPA 漏出去

让 Service 直接依赖 JpaRepository<Article, Long> 最省事,但代价是 Page、Sort、Specification、deleteAllInBatch 全部进入业务层。换存储、加缓存、写单测时都要面对这些类型。

可行的折中是:简单 CRUD 直接用 Spring Data 接口,复杂查询定义端口(port)+ 适配器(adapter),端口只返回业务需要的形状。

public interface ArticleRepository {
    Optional<Article> findById(Long id);
    Article save(Article article);
    PageResult<Article> findPage(ArticleQuery query);
}

适配器实现里把 Pageable、Specification 这些 JPA 类型挡在内部,对外只返回 PageResult;PageResult 是自己定义的 record,page 从 1 开始,total 是总条数。这样 Controller 不依赖 Spring Data,换 MyBatis 或换 Elasticsearch 时只需要重写适配器。查询条件的组装也放在适配器内部,由 ArticleQuery 这个业务对象驱动,而不是让 Service 拼 Specification。

还有一个纪律:Repository 接口不返回 Page、不暴露 EntityManager、不接受 Specification。否则「可替换」只是纸面上的。

8. 异常分层与统一错误响应

异常要分层,否则 catch (Exception e) 会变成唯一策略。我的做法是三层:

  • 业务异常:BusinessException 及子类,携带业务码和 HTTP 状态,属于预期内。
  • 校验异常:MethodArgumentNotValidException、ConstraintViolationException,由框架抛出。
  • 未知异常:其余全部,记录完整日志,对外只返回 500。
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException ex) {
        return ResponseEntity.status(ex.getStatus()).body(ErrorResponse.of(ex.getCode(), ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        List<FieldErrorItem> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(e -> new FieldErrorItem(e.getField(), e.getDefaultMessage()))
                .toList();
        return ResponseEntity.unprocessableEntity().body(ErrorResponse.validation(errors));
    }
}

另外还要有一个兜底的 @ExceptionHandler(Exception.class):记录带堆栈的完整日志,对外只返回 500 和一句通用文案。兜底分支的价值不在于「处理」,而在于保证任何未预期异常都不会以 HTML 错误页或裸堆栈的形式回到客户端。

业务码和 HTTP 状态码是两个维度:HTTP 状态码给网关和重试策略看,业务码给前端做分支。不要用同一个字段承载两种含义,也不要把堆栈或 SQL 错误拼进 message。错误响应还应该带 traceId,否则用户截图反馈时,你在日志里对不上这一次请求。

9. 分页与排序也是契约

分页参数看着简单,实际是最容易出安全问题的地方:size=100000 拖垮数据库,sort=password 探测字段,page=0 让 PageRequest 直接抛异常。参数要在进入 Service 之前收紧。

我习惯用一个 ArticleQuery record 承载查询参数,在紧凑构造器里做三件事:页码小于 1 时归一到 1;size 缺省 20、上限 100;排序字段只允许命中白名单。白名单之外的值直接抛业务异常,而不是交给 Spring Data 去解释——Sort 本身不认识字段是否敏感,它只会把属性路径拼进查询。

约定要写进文档:页码从 1 开始,size 上限 100,默认按 createdAt 倒序,并且排序时追加主键作为 tiebreaker。最后这条不是洁癖:排序值相同的记录在翻页时顺序不稳定,会出现同一条记录跨页重复或整条消失。前端不需要猜,后端也不需要每次解释。

分页还有一个隐藏成本:count(*) 在大表上可能比查询本身还慢。数据量大时改用游标分页(WHERE id < :lastId ORDER BY id DESC LIMIT :size),接口契约要提前留好口子。

10. 越界写法清单

把上面散落的判断集中成一份清单,Code Review 时可以直接对照:

  1. Controller 注入 Repository,或直接写 @Transactional。
  2. Entity 出现在 @RequestBody 或返回值里。
  3. Service 返回 Page<Entity>,让 Controller 感知 Spring Data。
  4. 用 BeanUtils.copyProperties 做映射,字段改名后静默失效。
  5. 事务注解加在 private、final 方法或同类自调用上——代理不生效。
  6. 在 Controller 里 try/catch 后手工拼错误响应,绕过统一处理。
  7. 分页 size 不设上限,排序字段不做白名单。
  8. Repository 里写业务判断,比如「状态不是草稿就不许删」。
  9. DTO 直接复用 Entity 的字段名和嵌套结构,改表即破坏 API。

11. 小结

分层的价值不在目录整齐,而在于每一层都能被单独替换和单独测试:Controller 的测试不需要数据库,Service 的测试不需要 Spring MVC,Repository 的测试不需要启动整个应用。

判断边界是否划对,有一个简单问题:如果明天把 JPA 换成 MyBatis,哪些文件要改?答案应该只有 Repository 的适配器。如果 Service 和 Controller 也要动,说明 JPA 已经漏出去了。

下一篇写认证:Spring Security 6 的无状态 JWT 配置,以及 access token、refresh token 轮换和服务端撤销之间那个绕不开的矛盾。