Controller 变胖不是风格问题,是耦合问题。它一旦开始判断业务、拼 SQL、写事务,就同时绑住三样东西:HTTP 协议、业务规则、持久化细节。任何一样变化都要动它,而它又必须起容器、造 MockMvc 才能测。这篇只谈边界落在哪里、怎么判断越界、越界以后往哪搬。
1. 三层各自的独占职责
| 层 | 独占职责 | 不该出现 | 判据(出现即越界) |
|---|---|---|---|
| Controller | 协议转换、参数绑定、格式校验、状态码与响应体形态 | 业务分支、事务、SQL、循环拼装 | 出现业务状态的 if、@Transactional、循环里调 Service |
| Service | 用例编排、事务边界、领域规则、跨聚合协作 | HTTP 对象、HttpServletRequest、ResponseEntity |
签名含 *Request/*Response,或 import jakarta.servlet |
| Repository | 持久化、查询组装、映射 | 业务规则、事务编排、远程调用 | 出现业务条件分支,或在 Mapper 里调 Feign/RestClient |
用法是逐行 grep,不是逐行读代码:@Transactional 出现在 @RestController 类里即是硬伤。
1.1 「薄」的可操作判据
不靠感觉,用四条机械标准:
| 判据 | 合规 | 越界信号 |
|---|---|---|
| 方法体行数 | 通常 3–15 行 | 超过 30 行基本含业务逻辑 |
| 缩进深度 | 不超过 2 层 | 出现嵌套 if/for |
| 调用的 Service 数 | 一般 1 个 | 连续调 3 个以上再手工拼装 |
| 返回对象 | 直接返回 Service 结果或一次转换 | 方法内 new 复合响应体并逐字段赋值 |
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderApplicationService orderService;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderDetailResponse create(@Valid @RequestBody CreateOrderRequest request) {
return OrderDetailResponse.from(orderService.create(request.toCommand()));
}
@GetMapping("/{id}")
public OrderDetailResponse get(@PathVariable Long id) {
return OrderDetailResponse.from(orderService.detail(id));
}
}
2. Controller 变胖的六种形态
按频率排列。共同点是都能写出「看起来正常」的版本,只是职责错位。
| # | 形态 | 典型写法 | 该上移到哪 | 代价 |
|---|---|---|---|---|
| 1 | 拼返回体 | 循环里补用户名、补文案 | Service 或 Assembler | 契约散落,改字段要动 N 个 Controller |
| 2 | 做权限判断 | if (!user.isAdmin()) throw |
方法级注解 + 拦截器 | 规则无法复用,接口之间不一致 |
| 3 | 写事务 | Controller 上加 @Transactional |
Service 用例方法 | 事务覆盖序列化与网络写,连接占用拉长 |
| 4 | 多 Service 拼装 | aService.find() + bService.find() 手工 join |
应用服务编排或查询侧聚合 | 一次查询变 N 次调用 |
| 5 | 做缓存 | Controller 里手写 redisTemplate |
Service 或缓存切面 | 缓存键规则分散,失效无从下手 |
| 6 | 做计算 | 金额换算、状态机推进、时间窗口 | Service 或领域对象 | 规则被复制到多个入口,改一处漏一处 |
// 越界:一个方法里同时混进权限、缓存、事务与拼装
@PostMapping("/{id}/confirm")
public ResponseEntity<?> confirm(@PathVariable Long id, HttpServletRequest req) {
Long userId = (Long) req.getSession().getAttribute("uid");
Order o = orderService.get(id);
if (!o.getUserId().equals(userId) && !req.isUserInRole("ADMIN")) { // 权限
return ResponseEntity.status(403).body("no");
}
redisTemplate.delete("order:detail:" + id); // 缓存
o.setStatus(2);
orderService.update(o); // 事务缺失
User u = userService.get(o.getUserId()); // 拼装
return ResponseEntity.ok(Map.of("orderNo", o.getOrderNo(), "userName", u.getName()));
}
3. Service 变胖的下一个陷阱
Controller 瘦下来之后逻辑会全堆到 Service,于是出现第二个坑:万能 Service。症状是类越来越长、方法间共享私有字段、单测要 mock 十几个依赖。
| 拆法 | 拆的依据 | 适用信号 | 风险 |
|---|---|---|---|
| 按用例拆 | 一个写用例一个类(CreateOrderHandler) |
方法之间几乎不共享状态 | 类数量增加,编排层变多 |
| 按聚合拆 | 一个聚合根一个 Service | 存在明确聚合根与不变量 | 跨聚合事务需额外编排 |
| 按读写拆 | 写走领域对象,读走查询服务直出 DTO | 查询侧复杂、报表多 | 写模型与读模型容易漂移 |
编排层只做转发与事务组合:
@Service
@RequiredArgsConstructor
public class OrderApplicationService {
private final CreateOrderHandler createHandler;
private final CancelOrderHandler cancelHandler;
private final OrderQueryService queryService;
@Transactional
public OrderDetail create(CreateOrderCommand cmd) { return createHandler.handle(cmd); }
@Transactional
public void cancel(CancelOrderCommand cmd) { cancelHandler.handle(cmd); }
public OrderDetail detail(Long id) { return queryService.detail(id); }
}
3.1 贫血模型的连带效应
Service 变胖往往和贫血模型是一对:实体只有 getter/setter,规则全写在 Service,「状态能否从 A 到 B」在 pay、cancel、refund 各写一遍。规则搬回实体,Service 自然变瘦:
public class Order {
private OrderStatus status;
public void cancel(String reason) {
if (status != OrderStatus.CREATED && status != OrderStatus.PAID) {
throw new IllegalStateException("当前状态不可取消: " + status);
}
this.status = OrderStatus.CANCELED;
}
}
4. 跨层调用、DTO 与异常
4.1 Controller 直接调 Mapper 的代价
| 丢掉的能力 | 后果 |
|---|---|
| 事务边界可控 | 一个请求里多次独立查询,无法在同一快照上读 |
| 规则收口 | 权限校验、租户过滤、逻辑删除无处安放 |
| 可替换性 | 表结构变化直接冒到 HTTP 层 |
唯一例外:纯只读、无规则、无复用的字典查询。
4.2 DTO 转换放哪
| 位置 | 做法 | 适用 | 不适用 |
|---|---|---|---|
| Controller 内静态工厂 | OrderDetailResponse.from(detail) |
简单映射 | 字段多、有条件映射 |
| 独立 Assembler | OrderAssembler.toDetail(order) |
多接口复用映射 | 一次性接口 |
| MapStruct | 编译期生成实现 | 字段多、需批量映射 | 映射含业务分支 |
| Service 返回 DTO | 查询侧直出 DTO | CQRS 读模型 | 写模型(污染领域层) |
原则:领域对象不外泄出 Service,DTO 不进 Service 入参(写用例用 Command)。这条边界比「用不用 MapStruct」重要得多。
4.3 异常在哪一层转换
只做一次,放在最外层:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> onBusiness(BusinessException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> onInvalid(MethodArgumentNotValidException ex) {
return ResponseEntity.badRequest().body(new ErrorResponse("PARAM_INVALID", "参数校验失败"));
}
}
Service 抛业务异常,领域层抛语义异常(IllegalStateException),@RestControllerAdvice 翻译成状态码与错误体;中间层不 catch 再包装。
5. 分层的可测性差异
| 层 | 测试类型 | 起 Spring 容器 | 典型用例 |
|---|---|---|---|
| Controller | 契约测试(@WebMvcTest) |
否(切片) | 参数校验、状态码、序列化字段名 |
| Service | 单元 + 集成测试 | 写用例需要 | 用例分支、事务回滚、异常路径 |
| Repository | @DataJpaTest / Testcontainers |
需要 | SQL 正确性、索引命中 |
Controller 越薄,切片测试价值越高:mock 一个 Service 就能断言契约;一旦含业务逻辑,切片测试退化成「再测一遍 Service」。
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderApplicationService orderService;
@Test
void create_returns201_and_bodyShape() throws Exception {
given(orderService.create(any())).willReturn(OrderFixture.detail());
mvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"items\":[{\"skuId\":1,\"qty\":2}]}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.orderNo").exists());
}
}
6. 排错:分层被破坏的六种表现
| 现象 | 根因 | 处理 |
|---|---|---|
| 改字段要动多个 Controller | 返回体在 Controller 里手工拼装 | 抽 Assembler,响应体收口 |
| 线上出现部分更新的脏数据 | 事务注解加在 Controller 或 private 方法上 | 移入 Service 的 public 用例方法 |
| 单测跑一次要十几秒 | Service 依赖 Servlet 对象或静态上下文 | 剥离 HTTP 对象,签名换 Command |
LazyInitializationException |
Controller 访问未加载关联 | 查询侧直出 DTO,实体不带出事务 |
| 两个 Service 互相注入 | 用例被切到了错误的边界 | 抽公共下游 Service,或合并用例 |
「薄」得离谱,逻辑进了 XxxUtils |
用工具类规避分层 | 工具类只放无状态纯函数 |
最后一条最易被忽略:把逻辑搬进 OrderUtils,Controller 薄了,耦合没少,还更难被测出。
7. 边界:什么时候这套判据不适用
| 场景 | 是否坚持三层 | 说明 |
|---|---|---|
| CRUD 后台、字段进出库 | 可放宽 | 确无业务规则时 Controller 可直连查询服务 |
| 纯查询报表接口 | 可放宽 Service | 查询服务直出 DTO |
| 强规则核心域 | 必须收紧 | 权限、状态机、金额计算一律下沉 |
| 小团队、迭代快 | 结构可简化 | 层数可减,但事务与 HTTP 不能混 |
一句话:分层不是为了层数,而是「回答一次变更只需要看一个地方」。
8. 自测
- 你会用哪几条机械判据判断一个 Controller 是否越界?
- Controller 上出现
@Transactional会带来哪些具体后果? - 「按用例拆 Service」和「按聚合拆 Service」分别适合什么信号?
- 把逻辑从 Controller 搬到
XxxUtils为什么不算解决耦合? - DTO 与 Command 分别应该出现在哪一层的签名里?
- Controller 的切片测试真正能守住的是什么?