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

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐