为什么Java里面,Service 层不直接返回 Result 对象?

前言

昨天在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 带来了两个问题:

  1. 不知道 Result 里到底包含什么,还得去查看代码里面的实现,写起来麻烦。

  2. 还需要额外判断 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();
}

这种做法有几个问题:

  1. 重复的错误处理:每个方法都得写一大堆类似的错误判断代码,增加了代码量。

  2. 错误分散:错误处理分散在每个方法里,如果需要改进错误逻辑,要在多个地方修改,麻烦且容易出错。

而如果我们通过抛出异常并结合全局异常处理来统一处理错误,例如:

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 和头发总有一个会先来。

🤣

相关推荐
BD_Marathon2 小时前
搭建MyBatis框架之创建MyBatis的映射文件(五)
java·数据库·mybatis
洛阳泰山2 小时前
智能体项目MaxKB4J - 本地部署与开发完整指南
java·agent·工作流·rag·智能体·maxkb
求梦8202 小时前
字节前端面试复盘
面试·职场和发展
Solar20252 小时前
机械制造业TOB企业获客软件选型指南:从挑战到解决方案的深度解析
java·大数据·服务器·架构·云计算
星火开发设计2 小时前
C++ stack 全面解析与实战指南
java·数据结构·c++·学习·rpc··知识
宋情写2 小时前
JavaAI06-SpringAI
java·人工智能
Hello.Reader2 小时前
Flink Avro Format Java / PyFlink 读写、Schema 细节与坑点总结
java·python·flink
人道领域3 小时前
【零基础学java】(反射)
java·开发语言
C雨后彩虹3 小时前
书籍叠放问题
java·数据结构·算法·华为·面试