一个团队的仓储接口从 return OrderPO 改成 return Order(领域实体),理由是"少一层转换"。半年后两个问题浮现:列表页需要"只要 3 个字段",而实体要求"全部装配"(于是"查 20 条要 20 次 join");报表需要"跨聚合的聚合值",而实体里没有(于是"又加了一个 PO 方法在同一个仓储里")。
仓储的"返回类型"决定了三件事:谁负责装配、能不能"只查要用的"、以及**"一个仓储"到底服务几个视角**.
"返回类型"这件事,其实在"定'谁有资格知道表结构'":返回 PO,"表结构"泄露给"调用方";返回实体,"表结构"被"仓储"藏起来,而"装配成本"也一起被藏起来。于是"两种选择"不是"抽象程度"的高低,而是"把哪种成本放在哪一层"。
1. 三种"返回类型"的取舍
| 返回 | 装配责任 | 灵活性 |
|---|---|---|
| PO/行对象 | 调用方 | 高(能"只查字段") |
| 领域实体 | 仓储 | 低(要"完整装配") |
| 读模型(DTO/投影) | 查询侧 | 最高(按场景定制) |
第二项"看起来最干净",而它"把'装配'压给了仓储"——于是"任何'只要一部分'的场景",都要"在仓储里加个方法".
"事务外访问集合"的故障"看一眼栈就知道",而它"根因在'返回类型'":
// 典型栈(截取)
org.hibernate.LazyInitializationException: failed to lazily initialize a collection
at Order.getItems(Order.java:88) ← 实体在"事务之后"被访问
at OrderService.toDto(OrderService.java:42)
// 判据:栈里出现"业务 getter" → 是"实体带出了事务边界"
2. "返回实体"带来的三种压力
| 压力 | 表现 |
|---|---|
| 装配成本 | 列表页"只要 3 字段"也要"装全" |
| 方法膨胀 | 仓储里出现 findForList/findForReport |
| 事务边界变宽 | 装配"懒加载"时"事务已结束" |
第三项是"最典型的故障":"返回实体、离开事务后访问集合"→ LazyInitializationException——而"修法"往往是"打开 open-in-view",把问题藏起来.
"拆两个接口"在代码上"只是多一个 interface",而它"把冲突变成分工":
// 两个接口,同一个实现类
interface OrderCommandRepo { Order load(AggregateId id); void save(Order o); }
interface OrderQuery { List<OrderRow> listByUser(long uid); OrderDetail detail(long id); }
// 判据:写侧"要实体",读侧"要投影"——互不影响
"open-in-view=true"之所以"看起来能修 LazyInitializationException",是因为它"把事务延长到'渲染完成'"——于是"懒加载"能在"视图层"触发。而它的代价是"连接被占到最后",在"高并发"下"连接池被吃满";所以它"是"止疼片",不是"治疗"——**"治疗"是"别把实体带出事务".
3. "读写分离"的三种做法
| 做法 | 说明 |
|---|---|
| 写侧"仓储返回实体" | 命令路径"完整装配"合理 |
| 读侧"查询返回投影" | 按场景"只查要用的" |
| 一个仓储"两个接口" | 写仓储 + 读查询(CQRS-lite) |
第三项是"最小改动":"同一个类"分两个接口——OrderRepository(写)与"OrderQuery(读)"——**于是"读"不再污染"写"的接口.
"构造器投影"的写法"明确到字段",而它"自带'少查就少错'":
// 只查 3 个字段(构造器投影)
public record OrderRow(long id, String status, BigDecimal amount) {}
@Query("select new com.x.OrderRow(o.id, o.status, o.amount) from OrderPO o where o.userId = :uid")
List<OrderRow> listByUser(@Param("uid") long uid);
// 判据:SQL 里"只出现 3 列"
"接口投影"在"重构"时最危险:它"按 getter 名"动态代理——于是"改了字段名","编译期不报错",而"运行时"才抛"找不到方法"。而"构造器投影"是"显式的":**字段名改了"编译就红",于是"改的人"必然"顺手改查询".
4. "投影"的三种写法
| 写法 | 说明 |
|---|---|
| 接口投影(Spring Data) | 声明"要哪些 getter" |
构造器投影(new Dto(...)) |
类型安全、可控 |
| 原生 SQL + 映射 | 最灵活(要"防注入") |
第二项是"最稳":"DTO 的构造器"明确列出"要哪些字段"——**于是"改了表"时,"编译器"会告诉你"哪里要改".
"方法膨胀"有个"可量化信号",比"感觉"可靠:
// 三个"信号"
1 同一个仓储里出现"findFor/listFor/detailFor"(≥3 个"用途后缀")
2 方法数 > 10,且"一半只被'页面'调用"
3 有方法"永远返回 null 的字段"(装了一半)
// 判据:命中任一 → 该拆"读/写"
5. "一个仓储服务几个视角"的判据
| 判据 | 结论 |
|---|---|
| 只有"命令"用它 | 返回实体 |
| 只有"查询"用它 | 返回投影 |
| 两者都有 | 拆两个接口 |
第三项是"现实里最常见"的:"同一个聚合"既有"下单"也有"列表"——**而"一个接口同时服务两者",必然"某一方要让步".
"列表页 N+1"的修复"先看 SQL 条数",而它"不必猜":
// 三步"确认 N+1"
1 打开"SQL 日志"(或 p6spy)
2 请求一次列表(20 条),数"select 语句"
3 若"20+ 条" → 是"实体装配"逐条加载
// 判据:条数"与行数成正比" = N+1
"仓储方法越加越多"还有一种"缓解":"把'页面专用查询'挪到'查询服务/读模型'里"——于是"仓储只留'聚合的加载与保存'"。而"这条边界"一旦划清,"代码评审"就有了"可执行的判据":**"这个方法'返回的是聚合吗'?不是,它就不该在仓储里"。
6. 排错:五种"仓储返回问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 列表页"查 20 条 20 次 join" | 实体装配 | 改投影 |
LazyInitializationException |
事务外访问 | 拆读接口/预装配 |
| 仓储方法"越加越多" | 多视角混用 | 拆读/写 |
| "只用 3 字段"却"查出全表" | 无投影 | 构造器投影 |
| 改表后"处处编译错" | 投影过宽 | 缩小 DTO |
"详情页"之所以"可以返回实体",是因为"它只要一条":
// 两种"读"的成本对比
详情(1 条):装配"1 次 join 组" → 几十毫秒,可接受
列表(20 条):装配"20 次 join 组" → 几百毫秒到秒级
// 判据:装配成本 × 行数 —— 行数一大就必须投影
7. 边界:什么情况"返回实体就是对的"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 纯"命令"路径(下单/审批) | 需要"完整不变式" | 返回实体 |
| 单条详情页(一次查一条) | 装配成本可接受 | 返回实体 |
| 聚合很小(< 5 字段) | 投影收益小 | 返回实体 |
| 已有"缓存层"(装一次多用) | 装配被摊薄 | 返回实体 |
判断口径一句话:"这个调用方,会不会"只用实体的一部分""。会(列表、报表、下拉框),就该"投影";不会(命令路径要"整体校验"),"实体"才是对的——而"一个仓储同时服务两者",是"方法膨胀"与"装配浪费"的共同来源.
8. 自测
- 三种"返回类型"分别由谁负责装配?为什么"返回实体"把压力压给了仓储?
- "返回实体"的三种压力里,哪一种"最典型的故障"是什么?
- "读写分离"的三种做法里,哪一种"最小改动"?为什么它有效?
- "构造器投影"为什么比"接口投影"更稳?改表时它有什么好处?
- "一个仓储服务两者"为什么"必然有一方要让步"?
- 判断"该返回实体还是投影"的那个问题是什么?
补一句"演进"建议:"先'读投影'、后'拆接口'"——因为"投影"是"局部改动"(一个方法、一个 DTO),而"拆接口"是"结构改动"(要动调用方);先在"最痛的列表页"上做"投影",收益"立刻可见",而"是否值得拆成 CQRS"可以"等第二个痛点出现再定"。
9. 一句话总结
仓储的返回类型,本质是"装配责任放在哪":返回实体 = 仓储负责装配(命令路径合理、读场景浪费);返回投影 = 查询侧负责(读场景高效、命令路径不够)。而现实里"同一个聚合既有命令又有查询",于是"拆成写仓储 + 读查询"几乎总是正解——它让"完整装配"与"只查要用的"各自成立,而不是"让某一方永远让步"。