一个被严重低估的 Java 工程设计问题
在很多 Java 项目中,我们经常能看到这样的代码演进过程:
// 初版
getOrder(Long orderId);
// 第二版
getOrder(Long orderId, Integer status);
// 第三版
getOrder(Long orderId, Integer status, LocalDateTime startTime);
// 第四版(崩了)
getOrder(Long orderId, Integer status, LocalDateTime startTime, LocalDateTime endTime, Long userId);
直到有一天,有人站出来说了一句:
“我们直接传一个查询对象吧。”
很多人听完的第一反应是:
- 查询而已,传这么重的对象是不是过度设计?
- 直接参数不是更清晰吗?
- DTO / VO / Query 到底有什么区别?
但在真正做过复杂系统、长期维护系统的人眼里,
查询传实体,往往不是设计洁癖,而是工程理性。
本文将从 工程演进、代码稳定性、可扩展性、分层解耦 四个角度,把这个问题一次性讲透。
一、从“参数爆炸”开始说起
先看一个真实的订单查询接口。
1️⃣ 参数式查询(初期)
public List<Order> queryOrder(
Long orderId,
Long userId,
Integer status,
LocalDateTime startTime,
LocalDateTime endTime) {
...
}
看起来没什么问题,但你很快会遇到:
- 参数越来越多
- 参数顺序极易写错
- 新增条件 = 修改所有调用方
- MyBatis / Controller / Service 层同时改
👉 接口一旦暴露,就很难再动
二、查询实体(Query Object)到底解决了什么?
1️⃣ 查询条件是“一个整体”,而不是一堆零散参数
在业务语义上:
“订单查询”本身就是一个概念
而不是:
“orderId + userId + status + timeRange 的组合体”
于是我们引入 查询实体(Query Object)。
三、标准做法:Query Object
1️⃣ 定义查询对象
/**
* 订单查询条件
*/
public class OrderQuery {
/** 订单号 */
private Long orderId;
/** 用户ID */
private Long userId;
/** 订单状态 */
private Integer status;
/** 创建时间开始 */
private LocalDateTime startTime;
/** 创建时间结束 */
private LocalDateTime endTime;
// getter / setter
}
👉 注意:这是 Query,不是 Entity,不是 DTO
2️⃣ Service 层接口立刻变得稳定
public List<Order> queryOrder(OrderQuery query) {
return orderMapper.selectByCondition(query);
}
此时你会发现一个关键变化:
接口签名稳定了
以后新增条件:
private Integer payType;
private Long merchantId;
- 不改 Service 方法
- 不改 Controller
- 不影响历史调用方
四、MyBatis 中 Query Object 的真正威力
Mapper 接口
List<Order> selectByCondition(OrderQuery query);
Mapper XML(核心价值)
<select id="selectByCondition" resultType="Order">
SELECT *
FROM orders
<where>
<if test="orderId != null">
AND order_id = #{orderId}
</if>
<if test="userId != null">
AND user_id = #{userId}
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="startTime != null">
AND create_time >= #{startTime}
</if>
<if test="endTime != null">
AND create_time <= #{endTime}
</if>
</where>
</select>
💡 Query Object = 天然的动态 SQL 载体
五、为什么不推荐 Controller 直接传一堆参数?
❌ Controller 参数式写法(问题多)
@GetMapping("/list")
public List<Order> list(
@RequestParam(required = false) Long orderId,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long userId) {
...
}
问题:
- 参数多 → 方法臃肿
- 参数校验零散
- 不利于复用
✅ Controller 直接接收 Query Object(推荐)
@GetMapping("/list")
public List<Order> list(OrderQuery query) {
return orderService.queryOrder(query);
}
Spring MVC 会自动完成参数绑定:
/list?orderId=1&status=2
👉 自动注入到 OrderQuery
六、从架构角度看:这是“边界清晰”的体现
查询实体的本质是:
把“变化点”封装起来
| 扩展性 | ❌ 差 | ✅ 极强 |
| 接口稳定性 | ❌ 易变 | ✅ 稳定 |
| 代码可读性 | ❌ 低 | ✅ 高 |
| 动态 SQL | ❌ 繁琐 | ✅ 天然支持 |
| 复杂查询 | ❌ 痛苦 | ✅ 友好 |
七、进阶:Query + Page + Sort 的标准模型
1️⃣ 基础查询对象
public class BaseQuery {
private Integer pageNum = 1;
private Integer pageSize = 20;
}
2️⃣ 订单查询继承
public class OrderQuery extends BaseQuery {
private Long orderId;
private Integer status;
}
3️⃣ 接口永远不再变化
public PageResult<Order> queryOrder(OrderQuery query) {
...
}
👉 这就是大型系统为什么“一开始就传实体”
八、什么时候不该用 Query Object?
不是所有地方都必须用。
❌ 不适合的场景
- 极简单查询(如:getById(id))
- 底层工具方法
- 极高频、对 GC 极度敏感的代码段(极少)
✅ 适合的场景
- 列表查询
- 条件组合查询
- 后台管理系统
需要长期维护的业务接口
九、一句话总结
查询传实体,不是为了现在,而是为了三个月、半年、一年后的系统。




