Controller 一层很喜欢包Result<T>,到了 Service 这一层,我一般第一眼就不太想看见它。
不是说不能跑。是这玩意一旦进了 Service,后面八成会越写越别扭:业务逻辑和接口语义搅在一起,方法签名一眼看不出到底在干业务,还是在凑返回格式;今天给 Web 用还行,明天给 MQ 消费、定时任务、RPC 复用的时候,味儿就全变了。这个判断和你给的几篇参考文的气质是一致的:文章不是讲“规范”本身,而是盯着真实编码现场里最容易失控的地方下手。
先看一段很多项目里都见过的写法:
@Service
publicclass OrderService {
public Result<OrderDTO> createOrder(CreateOrderCommand cmd) {
if (cmd.getUserId() == null) {
return Result.fail("用户不能为空");
}
Product product = productRepository.findById(cmd.getProductId());
if (product == null) {
return Result.fail("商品不存在");
}
if (product.getStock() <= 0) {
return Result.fail("库存不足");
}
Order order = new Order(cmd.getUserId(), cmd.getProductId(), cmd.getCount());
orderRepository.save(order);
return Result.success(new OrderDTO(order.getId(), order.getStatus()));
}
}
这段代码刚写出来,很多人觉得挺顺。校验、查库、保存、返回,一条龙。
但这个顺,只是 Controller 视角下的顺。
你把它放到 Service 层再看,就开始别扭了。
第一,Result.success()、Result.fail()这种东西,本质是接口返回协议。是给前端、给调用方、给 HTTP 用的。它应该出现在边界,不应该混进核心业务里。Service 的职责是“下单成功”还是“库存不足”,不是“code=200 还是 code=5001”。
第二,Service 一旦返回Result,调用方就会被迫跟着它的节奏走。
比如你后面有个定时补单任务,也要调这个方法:
@Scheduled(cron = "0/10 * * * * ?")
public void retryCreateOrder() {
CreateOrderCommand cmd = loadRetryTask();
Result<OrderDTO> result = orderService.createOrder(cmd);
if (!result.isSuccess()) {
log.warn("补单失败: {}", result.getMessage());
return;
}
log.info("补单成功: {}", result.getData().getId());
}
问题来了。定时任务根本不关心你是不是Result,它只关心成功没成功,失败是什么原因,要不要重试。
这时候Result就成了一个夹生层。看着像统一了,实际上是把 Web 层的壳子硬塞给所有调用方。
更麻烦的是,很多项目最后会演变成这样:
public Result<OrderDTO> createOrder(CreateOrderCommand cmd) {
try {
// 一堆业务逻辑
} catch (Exception e) {
log.error("下单异常", e);
return Result.fail("系统异常");
}
}
我对这种写法一直比较嫌弃。
因为异常被你在 Service 吃掉以后,事务回滚、重试、告警、错误分类,都会变得很钝。你在 Controller 里看见的只是一个Result.fail("系统异常"),但真实现场早就没了。
Service 层更合适的做法,是返回明确的业务对象,或者干脆返回void,失败就抛业务异常。
比如这样:
@Service
publicclass OrderService {
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderCommand cmd) {
if (cmd.getUserId() == null) {
thrownew BizException("用户不能为空");
}
Product product = productRepository.findById(cmd.getProductId());
if (product == null) {
thrownew BizException("商品不存在");
}
if (product.getStock() < cmd.getCount()) {
thrownew BizException("库存不足");
}
product.decreaseStock(cmd.getCount());
Order order = new Order(cmd.getUserId(), cmd.getProductId(), cmd.getCount());
orderRepository.save(order);
return order;
}
}
然后让 Controller 去做它该做的包装:
@RestController
@RequestMapping("/orders")
publicclass OrderController {
@PostMapping
public Result<Long> create(@RequestBody CreateOrderRequest request) {
CreateOrderCommand cmd = new CreateOrderCommand(
request.getUserId(),
request.getProductId(),
request.getCount()
);
Order order = orderService.createOrder(cmd);
return Result.success(order.getId());
}
}
这样拆开以后,层次就干净了。
Service 只表达业务:能不能下单,为什么不能下单,下单后产出什么。
Controller 只表达协议:HTTP 返回什么结构,错误码怎么映射,提示文案怎么出。
别小看这层分离,后面复用的时候差别很大。
比如 MQ 消费下单:
@RabbitListener(queues = "order.create.queue")
public void consume(OrderCreateMessage message) {
CreateOrderCommand cmd = new CreateOrderCommand(
message.getUserId(),
message.getProductId(),
message.getCount()
);
orderService.createOrder(cmd);
}
这里就很自然。消费成功就是成功,失败直接抛异常,让 MQ 走重试或死信。你要是返回Result,还得再判断一遍isSuccess(),本来该靠异常机制解决的事,硬是绕了一层字符串判断。
还有一个经常被忽略的问题:Result会污染领域建模。
有些人写着写着,Repository、Manager、Feign 适配层全开始返回Result。最后每一层都在if (!result.isSuccess()) return result;。代码看起来统一,实际上业务链路全是胶水。
真正稳定的代码不是“每层都长一样”,而是“每层只干自己的事”。
当然,也不是说 Service 层绝对不能返回包装对象。
有一种情况可以接受:这个返回对象本身就是业务结果,而不是接口结果。比如支付处理后的状态聚合:
public class PayResult {
private boolean paid;
private String channelOrderNo;
private String failReason;
}
这没问题。因为它表达的是业务结果,不是 HTTP 协议里的code/msg/data三件套。
所以这事最后落下来,其实就一句话:
Result属于接口边界,不属于业务核心。
Controller 返回Result,很正常。 Service 返回领域对象、业务结果、或者直接抛异常,这才顺手。