分层图人人会画,难的是边界上那些「顺手就能做」的事: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/ 四个目录,只是分层的外观。真正的分层由四条规则定义:
- 依赖单向向内:Controller 依赖 Service,Service 依赖 Repository,Repository 依赖 JPA。反向依赖一律不允许,Repository 里出现
HttpServletRequest就是信号。 - 不跳层:Controller 不碰 Repository,Repository 不感知 Service。跳层省下的那点代码,会在加缓存、加审计、加事务时加倍还回来。
- 形状在边界转换:Entity 不跨出 Service,DTO 不进入 Repository。每一层只认自己那一层的数据形状。
- 事务只在 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 时可以直接对照:
- Controller 注入 Repository,或直接写
@Transactional。 - Entity 出现在
@RequestBody或返回值里。 - Service 返回
Page<Entity>,让 Controller 感知 Spring Data。 - 用
BeanUtils.copyProperties做映射,字段改名后静默失效。 - 事务注解加在
private、final方法或同类自调用上——代理不生效。 - 在 Controller 里
try/catch后手工拼错误响应,绕过统一处理。 - 分页
size不设上限,排序字段不做白名单。 - Repository 里写业务判断,比如「状态不是草稿就不许删」。
- DTO 直接复用 Entity 的字段名和嵌套结构,改表即破坏 API。
11. 小结
分层的价值不在目录整齐,而在于每一层都能被单独替换和单独测试:Controller 的测试不需要数据库,Service 的测试不需要 Spring MVC,Repository 的测试不需要启动整个应用。
判断边界是否划对,有一个简单问题:如果明天把 JPA 换成 MyBatis,哪些文件要改?答案应该只有 Repository 的适配器。如果 Service 和 Controller 也要动,说明 JPA 已经漏出去了。
下一篇写认证:Spring Security 6 的无状态 JWT 配置,以及 access token、refresh token 轮换和服务端撤销之间那个绕不开的矛盾。




评论
加载中…