当前位置: 首页 > news >正文

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 后端系统来说,先把这些基本功做好,比过早引入复杂设计更重要。

http://www.jsqmd.com/news/1131995/

相关文章:

  • MySQL 8.0 命令行实战:5分钟完成数据库连接与10个核心操作验证
  • Windows 10/11 离线安装 .NET Framework 3.5:DISM 命令 3 步解决 0x8024402C 错误
  • SQL Server 2022 嵌套查询实战:3类子查询与连接查询性能对比分析
  • MySQL 查询优化实战:从50题中提炼的5个索引设计与执行计划解读
  • 3种Transformer位置编码对比:Sinusoidal, Learned, RoPE 在长文本任务中的性能差异
  • HTML5+CSS3 登录注册页面实战:从零构建 2 个响应式表单(附完整源码)
  • 终极游戏模组管理器:XXMI-Launcher让你的游戏体验焕然一新
  • 从Viola-Jones到YOLO:目标检测20年演进中的3个关键范式转变
  • PostgreSQL 16.3 Windows 安装:3种端口冲突解决方案与 pgAdmin 4 连接测试
  • HarmonyKit | 鸿蒙新特性实战:从零构建开发者工具箱
  • SolidWorks_装配体设计11_间隙验证与测量
  • PyTorch BCEWithLogitsLoss pos_weight 参数详解:5:1 样本比下的 3 种加权策略对比
  • Proxmox VE 6.2 同机换盘迁移:3步恢复配置与4个常见启动错误排查
  • NumPy 与 PyTorch 矩阵运算对比:5个核心操作在 CPU/GPU 上的性能基准测试
  • UEFI Handle/Protocol 核心链表解析:6条链表交互与源码级图解
  • PyTorch 1.13 光伏功率预测实战:4种神经网络模型对比与72小时预测误差分析
  • C++ TensorRT Edge-LLM 边缘推理框架:从原理到实战
  • WinCC V7.5 VBS脚本操作SQL Server 2016:4种CRUD操作完整代码与3个关键连接参数
  • Linux LVM 根目录 100% 磁盘打满:3步定位 MySQL 日志并安全清理
  • MySQL 元数据查询对比:INFORMATION_SCHEMA vs SHOW 命令 vs DESC
  • MySQL 单元 6 数据视图学习笔记
  • Momentum 与 Adam 优化器对比:从 2D 损失曲面到 ResNet-18 训练效率分析
  • 提示词工程实战:从基础指令到RAG与Agent的AI应用开发指南
  • LitePal 3.2.3 数据库升级实战:3步完成表结构变更与数据迁移
  • Ubuntu 22.04 dpkg lock-frontend 锁冲突:3步精准定位并安全终止占用进程
  • 如何快速掌握Spek频谱分析器:面向初学者的完整音频分析指南
  • 领取Ai大模型token了
  • MySQL 8.2 命令行效率提升:3个高级技巧与5个常见错误规避
  • 5分钟搭建RobotFramework+SeleniumLibrary自动化测试环境
  • ANI-RSS元数据刮削:3步打造专业级动漫媒体库