前言
昨天在Code Review时,我发现阿城在Service层直接返回了Result对象。
指出这个问题后,阿城有些不解,反问我为什么不能这样写。
于是我们展开了一场技术讨论(battle 🤣)。
讨论过程中,我发现这个看似简单的设计问题,背后其实涉及分层架构、职责划分、代码复用等多个重要概念。
与其让这次讨论的内容随风而去,不如整理成文,帮助更多遇到同样困惑的朋友理解原因。
知其然,更知其所以然。
耐心看完,你一定有所收获。

正文
职责分离原则
在传统的MVC架构中,Service层和Controller层各自承担着不同的职责。
Service层负责业务逻辑的处理,而Controller层负责HTTP请求的处理和响应格式的封装。
当我们将数据包装成 Result 对象的任务交给 Service 层时,意味着 Service 层不再单纯地处理业务逻辑,而是牵涉到了数据处理和响应的部分。
这样会导致业务逻辑与表现逻辑的耦合,降低了代码的清晰度和可维护性。
看一个不推荐的写法:
kotlin
@Service
public class UserService {
public Result<User> getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
return Result.error(404, 用户不存在);
}
return Result.success(user);
}
}
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
return userService.getUserById(id);
}
}
上面代码中,Service 层不仅负责从数据库获取用户信息,还直接处理了返回的结果。
如果我们需要改变返回的格式,或者进行错误信息的标准化,所有 Service 层的方法都需要修改。这样会导致代码的高耦合。
相比之下,以下做法将展示逻辑留给 Controller 层,保证了业务逻辑的纯粹性:
kotlin
@Service
public class UserService {
public User getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException(用户不存在);
}
return user;
}
}
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return Result.success(user);
}
}
让每一层都专注于自己的职责。
可复用性问题
当Service层返回Result时,会严重影响方法的可复用性。
假设我们有一个订单服务需要调用用户服务:
scss
@Service
public class OrderService {
@Autowired
private UserService userService;
public void createOrder(Long userId, OrderDTO orderDTO) {
// 不推荐的方式:需要解包Result
Result<User> userResult = userService.getUserById(userId);
if (!userResult.isSuccess()) {
throw new BusinessException(userResult.getMessage());
}
User user = userResult.getData();
// 后续业务逻辑
validateUserStatus(user);
// ...
}
}
这种写法有个很明显的问题。
OrderService 作为另一个业务服务,业务之间的调用本来应该简单直接,但使用 Result 带来了两个问题:
-
不知道
Result里到底包含什么,还得去查看代码里面的实现,写起来麻烦。 -
还需要额外判断
Result的状态,增加了不必要的复杂度。
如果是调用第三方外部服务,需要这种包装还能理解,但在自己业务之间互相调用时,完全没必要这样做。
如果Service返回纯业务对象:
java
@Service
public class OrderService {
@Autowired
private UserService userService;
public void createOrder(Long userId, OrderDTO orderDTO) {
// 推荐的方式:直接获取业务对象
User user = userService.getUserById(userId);
// 后续业务逻辑
validateUserStatus(user);
// ...
}
}
代码变得简洁且符合直觉。
业务层之间直接传递业务对象,保持简单和清晰。
异常处理机制
有些 Service 层在业务判断失败后,会直接返回 Result.fail(xxx) 这样的代码,例如:
java
public Result<Void> createOrder(Long userId, OrderDTO orderDTO) {
if (userId == null) {
return Result.fail("用户ID不能为空");
}
// 后续业务逻辑
return Result.success();
}
这种做法有几个问题:
-
重复的错误处理:每个方法都得写一大堆类似的错误判断代码,增加了代码量。
-
错误分散:错误处理分散在每个方法里,如果需要改进错误逻辑,要在多个地方修改,麻烦且容易出错。
而如果我们通过抛出异常并结合全局异常处理来统一处理错误,例如:
typescript
public void createOrder(Long userId, OrderDTO orderDTO) {
if (userId == null) {
throw new BusinessException("用户ID不能为空");
}
// 后续业务逻辑
}
再通过全局异常捕获来转换为 Result:
kotlin
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
return Result.error(400, e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e); // 这里可以查看堆栈信息
return Result.error(500, "系统繁忙");
}
}
这样做的好处是:
-
减少重复代码:业务方法不再需要写重复的错误判断,代码更简洁。
-
集中错误处理:错误处理集中在一个地方,修改时只需修改全局异常处理器,不用改动每个 Service 层方法。
-
业务与错误分离:业务逻辑专注处理核心功能,错误处理交给统一的机制,代码更加清晰易懂。
而且异常可以携带更丰富的上下文信息,如果业务侧需要时,可以带上堆栈信息,便于一些问题的定位。
测试便利性
Service层返回业务对象而不是Result时,能够大大提升单元测试的便利性:
scss
@SpringBootTest
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testGetUserById() {
// 推荐的方式:直接断言业务对象
User user = userService.getUserById(1L);
assertNotNull(user);
assertEquals(张三, user.getName());
}
@Test
public void testGetUserById_NotFound() {
// 推荐的方式:断言抛出异常
assertThrows(BusinessException.class, () -> {
userService.getUserById(999L);
});
}
}
如果Service返回Result,测试代码则需要写得更复杂:
scss
@Test
public void testGetUserById() {
// 不推荐的方式:需要解包Result
Result<User> result = userService.getUserById(1L);
assertTrue(result.isSuccess());
assertNotNull(result.getData());
assertEquals(张三, result.getData().getName());
}
测试代码变得莫名冗长,还得去关注响应结构,这并不是Service层测试的关注点。
Service 层本应专注于业务逻辑,测试也应该直接验证业务数据。
领域驱动设计角度
再换个角度。
从领域驱动设计(DDD)的角度来看,Service 层属于应用层或领域层,应该使用领域语言来表达业务逻辑。
而 Result 是基础设施层的概念,代表 HTTP 响应格式,不应该污染领域层。
例如,考虑转账业务:
scss
@Service
public class TransferService {
public TransferResult transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
Account fromAccount = accountRepository.findById(fromAccountId);
Account toAccount = accountRepository.findById(toAccountId);
fromAccount.deduct(amount);
toAccount.deposit(amount);
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
return new TransferResult(fromAccount, toAccount, amount);
}
}
在这个例子中,TransferResult 是一个领域对象,代表了转账的结果,包含了与业务相关的意义,而不是一个通用的 HTTP 响应封装 Result。
这种做法更符合领域模型的表达,体现了领域层的职责------处理业务逻辑,而不是涉及 HTTP 响应格式的细节。
接口适配的灵活性
当 Service 层返回纯粹的业务对象时,Controller 层可以根据不同的接口需求灵活封装响应:
less
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
private UserService userService;
// REST接口返回Result
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return Result.success(user);
}
// GraphQL接口直接返回对象
@QueryMapping
public User user(@Argument Long id) {
return userService.getUserById(id);
}
// RPC接口返回自定义格式
@DubboService
public class UserRpcServiceImpl implements UserRpcService {
public UserDTO getUserById(Long id) {
User user = userService.getUserById(id);
return convertToDTO(user);
}
}
}
同一个Service方法可以被不同类型的接口复用,每个接口根据自己的协议要求封装响应。
强行使用 Result 会导致接口的适配性变差,无法根据不同协议的需求灵活定制响应格式。
灵活性反而丢失了。
事务边界清晰
Service 层通常是事务边界所在,当 Service 返回业务对象时,事务的语义更加清晰:
scss
@Service
public class OrderService {
@Transactional
public Order createOrder(OrderDTO orderDTO) {
Order order = new Order();
// 设置订单属性
orderMapper.insert(order);
// 扣减库存
inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
return order;
}
}
在这个例子中,事务是围绕 Service 层的方法展开的,@Transactional 注解确保在业务逻辑执行失败时,事务会回滚。因为方法正常返回时,事务会提交;如果抛出异常,事务会回滚,事务的边界非常明确。
如果 Service 返回的是 Result,很难界定事务是否应该回滚。比如:
scss
public Result<Order> createOrder(OrderDTO orderDTO) {
Order order = new Order();
// 设置订单属性
orderMapper.insert(order);
// 扣减库存
Result<Void> inventoryResult = inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
if (!inventoryResult.isSuccess()) {
return Result.fail("库存不足");
}
return Result.success(order);
}
在这种情况下,如果库存不足,虽然 Result 返回失败信息,但事务并不会回滚,可能会导致数据不一致,反而还得额外去抛出异常。
而通过抛出异常的方式,事务的回滚语义非常清晰:异常抛出则回滚,方法正常返回则提交,这种设计确保了事务的边界更加明确,避免了潜在的数据一致性问题。
写在最后
看来阿城要走的路还很长,码路漫漫,踏浪前行。
2026年,祝大家加班少,薪水多,bug少,头发多,多写点注释,少走点弯路。
人生就像一个大项目,需求多,时间紧,但没关系------bug 和头发总有一个会先来。
🤣