Spring Boot 分层架构:让每一层只做自己该做的事
大多数 Spring Boot 入门教程都会告诉你「三层架构」,Controller → Service → Repository,看起来很清晰。但写了一段时间之后,你会发现代码开始自然地往两个方向腐烂:一种是 Controller 越来越胖,直接操作 Repository、做业务判断、拼装响应;另一种是 Service 变成「上帝对象」,1000 行的巨型类,什么都往里塞。
我踩过这两个坑之后,逐渐形成了一套对自己有效的约束原则。
Controller 的职责边界
Controller 只做三件事:接收请求、调用 Service、返回响应。判断、计算、数据库操作一概不属于它。
一个测试很有用:把 Controller 里所有 @Autowired 的依赖去掉,如果它没有任何有趣的逻辑要测,说明它足够薄——这是好事。
@RestController
@RequestMapping("/api/orders")
@Validated
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
Order order = orderService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(OrderResponse.from(order));
}
}注意:构造器注入而不是字段注入。这不只是风格问题——构造器注入在单元测试里不需要反射,依赖关系一眼可见,也更容易发现「这个类是不是依赖太多了」。
Service:事务边界就是业务边界
Service 是业务逻辑的家,也是事务边界的主人。一条经验:@Transactional 应该加在 Service 方法上,而不是 Repository 里,因为一个业务操作往往需要跨多个数据库操作保持原子性。
读操作记得加 @Transactional(readOnly = true),数据库可以做额外优化,也避免在只读方法里意外触发写操作。
DTO 和实体对象的转换放在 Service 层处理,不要让 Controller 直接拿到 JPA Entity——Entity 是持久化上下文的内部对象,让它泄漏到 HTTP 层会带来各种隐患:懒加载异常、不必要的字段序列化、或者前端意外修改了本该只读的字段。
异常处理:集中,而不是分散
我见过很多项目在每个 Controller 方法里写 try-catch,各自用不同的方式返回错误,最终前端维护了一个「玄学错误解析函数」来应对各种格式。
Spring 的 @ControllerAdvice 就是用来解决这个问题的。所有异常处理集中到一个地方:
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(ApiError.validation(message));
}
@ExceptionHandler(Exception.class)
ResponseEntity<ApiError> handleGeneric(Exception ex) {
log.error("unhandled_exception", ex);
return ResponseEntity.internalServerError()
.body(ApiError.of("Internal server error"));
}
}兜底的 Exception 处理务必记录完整堆栈,但对外只返回「Internal server error」——不暴露实现细节,不泄漏 SQL 语句或内部路径。
实现基于 IP 的限流时,直接从 Header 读 X-Forwarded-For 是危险的——客户端可以伪造这个头绕过限制。正确做法是配置 server.forward-headers-strategy=FRAMEWORK,注册 ForwardedHeaderFilter Bean,并确保反向代理是可信的且会覆盖(而不是追加)这个 Header。配置好之后,request.getRemoteAddr() 才会返回真实客户端 IP。
日志:结构化,带上上下文
log.info("开始处理订单") 几乎没有价值。线上出问题时你需要的是:哪个订单、谁触发的、耗时多少、哪一步失败了。
log.info("create_order userId={} itemCount={}", userId, items.size());
try {
// ...
} catch (Exception ex) {
log.error("create_order_failed userId={} reason={}", userId, ex.getMessage(), ex);
throw ex;
}键值对格式(key=value)比自然语言字符串更容易被日志系统解析和检索。catch 块里重新抛出异常前记得打完整的 ex,否则排查时只有错误信息没有调用栈。
再往上一步,用 OncePerRequestFilter 在请求维度记录耗时和状态码,能快速定位「哪个接口慢了」「哪个接口返回了大量 4xx」,不需要额外的监控工具就能有不错的基础可观测性。
一句话心法
Controller 负责接/发,Service 负责想,Repository 负责存——每层越安分守己,整个系统就越容易测试、越容易维护、越容易在出问题时快速定位。