Spring Boot 后端接口分层设计:从 Controller 到统一异常处理
在 Java 后端项目里,接口能不能长期维护,很多时候不取决于某个框架特性,而取决于最基础的工程结构:请求在哪里校验、业务逻辑放在哪里、异常怎么返回、对象如何在层与层之间流转。
这篇文章以 Spring Boot 为例,整理一套适合中小型业务系统的接口分层实践。它不追求复杂架构,而是解决日常开发中最常见的几个问题:
-
Controller 越写越厚,业务逻辑散落在接口层。
-
Entity 直接暴露给前端,字段变化容易牵连接口。
-
异常返回格式不统一,前端需要兼容多种错误结构。
-
Service 层缺少边界,事务、校验、组装逻辑混在一起。
一、推荐的后端分层结构
一个常见的 Spring Boot 模块可以按下面方式组织:
src/main/java/com/example/order ├── controller ├── service │ └── impl ├── repository ├── domain ├── dto │ ├── request │ └── response ├── converter ├── exception └── common
各层职责可以这样划分:
| 层级 | 主要职责 |
|---|---|
| Controller | 接收 HTTP 请求、参数校验、调用 Service、返回响应 |
| Service | 编排业务流程、控制事务边界、处理业务规则 |
| Repository | 数据访问,封装数据库操作 |
| Domain / Entity | 领域对象或数据库实体 |
| DTO | 接口入参和出参对象 |
| Converter | 负责 DTO、Entity、VO 之间的转换 |
| Exception | 自定义异常和统一异常处理 |
核心原则是:Controller 只做接口适配,Service 承担业务表达,Repository 只关心数据访问。
二、不要让 Controller 承担业务逻辑
很多项目后期难维护,是从 Controller 变厚开始的。比如下面这种写法:
@PostMapping("/orders")
public ApiResponse<Long> createOrder(@RequestBody CreateOrderRequest request) {
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new RuntimeException("用户不存在"));
if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new RuntimeException("订单金额必须大于 0");
}
Order order = new Order();
order.setUserId(user.getId());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.CREATED);
orderRepository.save(order);
return ApiResponse.success(order.getId());
}
这段代码短期看很直接,但它把用户查询、金额校验、订单创建、状态初始化、数据库保存都放进了接口层。后续如果有小程序、后台管理系统、批处理任务都需要创建订单,就很容易复制逻辑。
更好的方式是让 Controller 保持薄一点:
@PostMapping("/orders")
public ApiResponse<Long> createOrder(@Valid @RequestBody CreateOrderRequest request) {
Long orderId = orderService.createOrder(request);
return ApiResponse.success(orderId);
}
业务逻辑移动到 Service:
@Service
public class OrderServiceImpl implements OrderService {
private final UserRepository userRepository;
private final OrderRepository orderRepository;
public OrderServiceImpl(UserRepository userRepository, OrderRepository orderRepository) {
this.userRepository = userRepository;
this.orderRepository = orderRepository;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderRequest request) {
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new BusinessException("USER_NOT_FOUND", "用户不存在"));
if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException("INVALID_ORDER_AMOUNT", "订单金额必须大于 0");
}
Order order = new Order();
order.setUserId(user.getId());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.CREATED);
orderRepository.save(order);
return order.getId();
}
}
这样做之后,接口层只负责 HTTP 语义,业务层负责业务语义。后续即使换成消息消费、定时任务或内部 RPC 调用,也能复用 Service 逻辑。
三、接口入参与出参使用 DTO
很多新项目为了省事,会直接把 Entity 暴露给前端:
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
return orderRepository.findById(id).orElseThrow();
}
这会带来几个问题:
-
数据库字段变化会影响接口响应。
-
敏感字段可能意外返回给前端。
-
接口结构被数据库模型绑死,不利于演进。
-
前端需要的展示字段通常不是单个 Entity 能表达的。
建议使用独立的 Response DTO:
public class OrderDetailResponse {
private Long id;
private String orderNo;
private BigDecimal amount;
private String status;
private String statusText;
private LocalDateTime createdAt;
// getter/setter
}
Controller 返回 DTO:
@GetMapping("/orders/{id}")
public ApiResponse<OrderDetailResponse> getOrder(@PathVariable Long id) {
return ApiResponse.success(orderService.getOrderDetail(id));
}
转换逻辑可以放在 Converter 中:
public class OrderConverter {
private OrderConverter() {
}
public static OrderDetailResponse toDetailResponse(Order order) {
OrderDetailResponse response = new OrderDetailResponse();
response.setId(order.getId());
response.setOrderNo(order.getOrderNo());
response.setAmount(order.getAmount());
response.setStatus(order.getStatus().name());
response.setStatusText(order.getStatus().getDescription());
response.setCreatedAt(order.getCreatedAt());
return response;
}
}
DTO 的价值在于隔离变化。Entity 面向数据库,DTO 面向接口契约,二者不应该强绑定。
四、使用 Bean Validation 做基础参数校验
基础字段校验可以交给 Bean Validation,不要在业务代码里堆大量空值判断。
public class CreateOrderRequest {
@NotNull(message = "用户 ID 不能为空")
private Long userId;
@NotNull(message = "订单金额不能为空")
@DecimalMin(value = "0.01", message = "订单金额必须大于 0")
private BigDecimal amount;
@NotEmpty(message = "商品列表不能为空")
private List<OrderItemRequest> items;
// getter/setter
}
Controller 中增加 @Valid:
@PostMapping("/orders")
public ApiResponse<Long> createOrder(@Valid @RequestBody CreateOrderRequest request) {
return ApiResponse.success(orderService.createOrder(request));
}
需要注意的是,Bean Validation 适合做格式类、必填类、范围类校验。涉及数据库查询、库存判断、状态流转这类业务规则,仍然应该放在 Service 层。
五、统一响应结构
统一响应结构能减少前后端协作成本。一个简单的返回结构可以这样设计:
public class ApiResponse<T> {
private boolean success;
private String code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.success = true;
response.code = "OK";
response.message = "success";
response.data = data;
return response;
}
public static <T> ApiResponse<T> failure(String code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.success = false;
response.code = code;
response.message = message;
return response;
}
// getter/setter
}
成功响应:
{
"success": true,
"code": "OK",
"message": "success",
"data": {
"id": 1001,
"orderNo": "PO202607050001"
}
}
失败响应:
{
"success": false,
"code": "USER_NOT_FOUND",
"message": "用户不存在",
"data": null
}
响应结构不需要一开始设计得很复杂,但最好从项目早期就统一下来。
六、自定义业务异常
不要在业务代码里直接抛 RuntimeException,因为它缺少稳定的错误码,也不利于前端处理。
可以定义一个业务异常:
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}
在 Service 中使用:
if (!order.canCancel()) {
throw new BusinessException("ORDER_CANNOT_CANCEL", "当前订单状态不允许取消");
}
错误码建议稳定、可枚举、可搜索。错误提示可以调整,但错误码一旦被前端、客户端或第三方系统依赖,就不要轻易改变。
七、统一异常处理
Spring Boot 可以通过 @RestControllerAdvice 统一处理接口异常:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
return ResponseEntity
.badRequest()
.body(ApiResponse.failure(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(FieldError::getDefaultMessage)
.orElse("请求参数不合法");
return ResponseEntity
.badRequest()
.body(ApiResponse.failure("INVALID_PARAM", message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
return ResponseEntity
.internalServerError()
.body(ApiResponse.failure("INTERNAL_SERVER_ERROR", "系统繁忙,请稍后重试"));
}
}
这里有一个重要细节:返回给前端的错误信息要克制,日志里记录完整异常,接口响应只返回用户能理解、也适合暴露的信息。
实际项目中可以在兜底异常里加日志:
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
log.error("Unexpected server error", ex);
return ResponseEntity
.internalServerError()
.body(ApiResponse.failure("INTERNAL_SERVER_ERROR", "系统繁忙,请稍后重试"));
}
八、事务边界放在 Service 层
事务注解一般放在 Service 的 public 方法上:
@Override
@Transactional(rollbackFor = Exception.class)
public void payOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new BusinessException("ORDER_NOT_FOUND", "订单不存在"));
order.pay();
orderRepository.save(order);
paymentRecordRepository.save(PaymentRecord.of(order));
}
不建议把事务放在 Controller 层,因为 Controller 是接口适配层,不应该关心数据库事务。不建议把事务过度下沉到每个 Repository 方法,因为一次业务操作往往包含多个数据库动作,需要统一提交或回滚。
事务方法里还要注意几点:
-
避免在事务中做耗时外部调用,例如 HTTP 请求、文件上传、大批量远程查询。
-
注意 Spring AOP 的自调用问题,同一个类内部方法互相调用可能导致事务不生效。
-
对只读查询可以使用
@Transactional(readOnly = true)。
九、一个完整接口的最终形态
Controller:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ApiResponse<Long> createOrder(@Valid @RequestBody CreateOrderRequest request) {
return ApiResponse.success(orderService.createOrder(request));
}
@GetMapping("/{id}")
public ApiResponse<OrderDetailResponse> getOrder(@PathVariable Long id) {
return ApiResponse.success(orderService.getOrderDetail(id));
}
}
Service:
public interface OrderService {
Long createOrder(CreateOrderRequest request);
OrderDetailResponse getOrderDetail(Long id);
}
Service 实现:
@Service
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
public OrderServiceImpl(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderRequest request) {
Order order = Order.create(request.getUserId(), request.getAmount());
orderRepository.save(order);
return order.getId();
}
@Override
@Transactional(readOnly = true)
public OrderDetailResponse getOrderDetail(Long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new BusinessException("ORDER_NOT_FOUND", "订单不存在"));
return OrderConverter.toDetailResponse(order);
}
}
这套结构并不复杂,但它把接口、业务、数据、异常、响应分别放在了合适的位置。
十、总结
Spring Boot 项目的可维护性,往往来自一些朴素但稳定的习惯:
-
Controller 保持轻薄,只处理 HTTP 层逻辑。
-
Service 承载业务流程和事务边界。
-
Repository 专注数据访问。
-
Entity 不直接暴露给前端,接口使用 DTO。
-
参数校验使用 Bean Validation,业务校验放在 Service。
-
统一响应结构,统一异常处理。
-
错误码稳定,错误信息清晰,异常日志完整。
这些实践不会让项目一夜之间变成复杂架构,但能让代码在需求增长时仍然保持清晰。对于多数 Java 后端系统来说,先把这些基本功做好,比过早引入复杂设计更重要。
更多推荐
所有评论(0)