2026/10/31 7:44:50

Spring项目VO、QO、BO、PO分层落包与转换实践

Spring项目VO、QO、BO、PO分层落包与转换实践 做订单详情接口改造那阵子我把一个 Spring 项目里所有叫XxxVO、XxxQO、XxxBO的类全导出来列了一遍结果挺难看同一个业务对象有七个版本其中三个内容完全一样还有两个 VO 里躺着数据库字段is_deleted和内部风控标记。这类问题在中小团队里极其普遍——VO、QO、BO 在 Spring 项目中的位置表面看是包怎么建、类放哪儿的命名问题往深了说其实是谁拥有这份数据、谁有权修改它、谁能看见它的边界问题。放对了接口改动不牵连业务逻辑换前端不动后端放错了一个字段的增删能在三层之间引发连锁反应。这篇内容面向写过 Spring Boot、但对象分层一直靠感觉走的开发者我会把每个对象该落在哪一层、包怎么建、转换写在哪儿、以及我踩过的那些坑讲透看完你可以直接照着改自己手头的项目结构。1. 三个O到底在解决什么问题1.1 从一次订单接口改造说起当时的场景很典型运营要在订单列表加上最近一次退款时间前端要在详情页加一个预计送达倒计时。需求本身不复杂但代码是这样的——Mapper 直接返回OrderVOService 原样透传Controller 直接return orderVO。也就是说OrderVO同时兼任了数据库映射对象、业务对象和接口出参三个角色。加一个退款时间字段你得同时改 Mapper 的 resultMap、改 Service 的计算逻辑、还得确认前端序列化不受影响更麻烦的是详情页要的倒计时依赖的是一个耗时计算如果放在列表接口里做几百条数据会直接把响应时间拉长到秒级。问题的根子不在技术在于这个类没有明确的归属层。当同一个类同时被三层引用任何一层的新需求都会侵入其他层。后来我们做的第一件事不是重构代码而是先定义清楚OrderVO只属于接口层它只描述前端看到的样子订单的业务状态流转属于业务层用OrderBO或者干脆用领域实体表达数据库那张表长什么样只有持久层的OrderPO说了算。三件事拆开之后列表接口的 VO 里不放倒计时字段详情接口单独组装性能问题自然就没了。这个案例说明一件事对象分层不是为了好看是为了让变化被隔离在最小的范围内。前端改版只动 VO业务规则调整只动 BO表结构变更只动 PO。任何一次修改的影响半径取决于你当初把对象放在了哪儿。1.2 VO、QO、BO 与 DO/PO/DTO 的角色对照很多人分不清这几个O是因为不同团队、不同文章给的缩写含义不一致。我按国内 Spring 项目里最常见的一套约定来梳理这套约定在大多数中后台系统里足够用缩写全称中文归属层核心职责典型包路径VOView Object视图对象接口层面向前端/调用方的出参结构做字段裁剪与展示组装interfaces.vo或web.voQOQuery Object查询对象接口层/应用层收敛查询条件、分页排序参数负责入参校验interfaces.qo或application.queryBOBusiness Object业务对象领域层承载业务状态与业务行为是业务规则的落点domain.model或service.boPOPersistent Object持久化对象持久层与数据库表结构一一对应只做数据搬运infrastructure.persistence.poDTOData Transfer Object传输对象应用层/跨服务跨进程、跨模块传输比如 RPC 调用参数与返回application.dto或api.dtoDOData Object数据对象持久层多数团队里等价于 PO叫法不同而已同上这张表里最关键的一列不是缩写是归属层。VO 之所以叫视图对象是因为它的唯一消费者是视图页面或者外部调用方BO 之所以叫业务对象是因为它必须能表达订单已支付但不能发货这种业务状态而不只是几个字段的容器。QO 容易被忽略很多项目直接拿实体类接RequestBody参数一多就混乱查询条件的默认值、上限、日期区间校验全散落在 Service 里最后谁也不敢动。我把 DTO 单独列出来是因为它在 Spring 微服务项目里有实际用途服务 A 调服务 B 的 Feign 接口传的参数和接收的结果既不该用 B 的内部 BO耦合太深也不该用 B 的 VOVO 是给前端看的字段口径不同这时候用一组稳定的 DTO 做契约最合适。DTO 的定位是跨边界传输边界在哪儿DTO 就定义在哪儿。1.3 为什么 Spring 的注解让边界更容易糊掉Spring 这套东西太顺手了顺手到容易让人忘记对象之间的层次差异。Entity一贴类就成了表映射RestController一写方法返回值直接被序列化成 JSONRequestBody一加请求体自动变成对象。三个注解串起来一条从数据库到浏览器的直通车就打通了中间不需要任何转换代码。项目初期这么写效率极高类少、代码短、上手快。代价在半年后显现。Entity标注的类被 Jackson 序列化时懒加载的关联对象会触发额外查询甚至抛LazyInitializationException实体里加了JsonIgnore解决序列化问题结果内部 RPC 调用也用这个类把需要的字段一起忽略了为了防止前端看到敏感字段在实体字段上贴一堆JsonIgnore和JsonProperty实体类变成了注解垃圾场谁也说不清它到底是数据库映射还是接口契约。Spring 的设计本身没有问题问题在于它默认鼓励少写类。而分层的本质恰恰是多写几个类换来看得清的边界。我的判断标准很简单如果一个类上同时出现了持久化注解Entity、TableName、TableId和序列化/校验注解JsonFormat、NotBlank、ApiModelProperty那这个类的归属就已经乱了它至少有两条变化的理由迟早要拆。提醒不是所有项目都必须拆到 PO/BO/VO 全套。小项目、内部工具、原型阶段用两三个类换开发速度是合理取舍。但你要清楚这是主动选择而不是不知道还能拆因为一旦业务变复杂、多人协作、对外提供接口边界成本会以指数级还回来。2. 分层视角下每个对象该落在哪个包2.1 经典分层与对象的归属位置Spring 项目最通用的分层是四层接口层interfaces/web/adapter、应用层application、领域层domain、基础设施层infrastructure。这个结构来自六边形架构和整洁架构的简化版核心思想是依赖方向单向向内外层依赖内层内层不知道外层的存在。落到目录上我常用的一种结构长这样com.example.order ├── interfaces // 接口层面向外部 │ ├── controller // OrderController │ ├── vo // OrderDetailVO、OrderItemVO │ ├── qo // OrderPageQO、OrderExportQO │ └── assembler // OrderVoAssemblerBO/DO - VO ├── application // 应用层编排用例 │ ├── service // OrderAppService │ ├── dto // 对外RPC契约 │ └── converter // QO - 查询条件、DTO - BO ├── domain // 领域层业务规则 │ ├── model // OrderBO、OrderStatus │ ├── service // OrderDomainService │ └── repository // OrderRepository 接口只有接口 └── infrastructure // 基础设施层技术实现 ├── persistence │ ├── po // OrderPO、OrderItemPO │ ├── mapper // OrderMapperMyBatis │ └── repository // OrderRepositoryImpl └── config对照着看每个对象的落点就清楚了VO 和 QO 在interfaces下BO 在domain.model下PO 在infrastructure.persistence.po下。注意domain.repository里只有接口实现放在基础设施层这就是依赖倒置——领域层定义我需要什么能力基础设施层负责怎么实现领域层完全不认识 MyBatis 和数据库。有人会问 QO 是不是该放应用层。两种做法我都用过结论是看 QO 的用途如果 QO 只服务于 HTTP 接口放interfaces.qo如果同一个查询条件还要被定时任务、消息消费复用那它其实是应用层的输入模型放application.query更合适。关键是全项目统一别一半在接口层一半在应用层找代码的时候来回跳。2.2 按层分包还是按功能分包怎么选包组织有两种流派。按层分包是上面那种controller、service、dao各一个大包所有业务的类都往里塞。按功能分包也叫按模块分包是先按业务域切分每个域内部再分层com.example ├── order // 订单域 │ ├── interfaces │ ├── application │ ├── domain │ └── infrastructure ├── payment // 支付域 │ └── ... └── user // 用户域 └── ...我的实际体验是业务域少于五个、团队三五个人的项目按层分包够用一旦域变多、开始有跨域调用按功能分包明显更好维护。原因是按层分包时改一个订单需求要横跨四个大包找文件而且包内类数量膨胀到几十个之后IDE 的包视图基本失去导航价值按功能分包时一个需求的代码集中在一个目录子树里边界也天然清晰——订单域不该直接引用支付域的 PO。还有一个容易被忽视的细节不要把 VO 和 PO 放在同一个模块里。如果项目是多模块 Maven/Gradle 结构接口契约VO/QO/DTO应该放在独立的xxx-api模块业务实现放在xxx-service模块。这样前端团队或者其他服务只依赖 api 模块就能拿到契约类不会把持久层、MyBatis 依赖一起拖进去。我见过一个项目把 PO 和 VO 混在一个模块结果调用方为了用一个 VO被迫引入了整个 ORM 框架和数据库驱动启动时还试图连库排查了半天。2.3 依赖方向谁能看见谁对象放对位置只是第一步更关键的是约束谁能引用谁。我总结成几条硬规则团队里可以直接当约定用接口层可以依赖应用层、可以依赖 VO/QO但不允许直接依赖 PO 和 Mapper。应用层可以依赖领域层可以引用 BO但不应该出现 VO 类型——Service 返回 VO 是典型的层次倒置。领域层只依赖自身的模型和仓储接口不能出现 Spring Web、MyBatis、Jackson 的任何类型。持久层实现仓储接口PO 只在持久层内部流转出持久层必须转成 BO 或明确的查询结果对象。这几条里最常被破的是第二条。Controller 图省事让 Service 直接返回 VO理由是反正就这几个字段。一旦这么写Service 就被绑死在当前接口上了同一个业务逻辑要给后台管理端和小程序端用两边字段要求不同你只能在 Service 里写 if-else 判断调用来源或者复制一份方法。正确的做法是 Service 返回 BO 或者应用层 DTO由接口层的 Assembler 各自组装成不同的 VO。多写一个转换类换来的是 Service 可以复用。怎么保证这些规则不被悄悄破坏纯靠 Code Review 不靠谱人会疲劳。我推荐用 ArchUnit 写几条架构测试把它变成 CI 的一部分AnalyzeClasses(packages com.example.order) class LayerArchitectureTest { ArchTest static final ArchRule domain_should_not_depend_on_infrastructure noClasses().that().resideInAPackage(..domain..) .should().dependOnClassesThat() .resideInAnyPackage(..infrastructure.., org.springframework.web.., org.apache.ibatis..); ArchTest static final ArchRule vo_should_only_live_in_interfaces classes().that().haveSimpleNameEndingWith(VO) .should().resideInAPackage(..interfaces.vo..); }这类测试写起来十分钟跑起来几毫秒但能在别人包括三个月后的你不小心 import 错包时立刻报错。比在文档里写十条规范有用得多。3. 字段设计与对象转换的实操细节3.1 VO 的字段裁剪与组装策略VO 的设计原则只有一条前端需要什么就给什么一个字都不多给。听起来简单做起来要处理三类问题。第一类是字段形态。数据库存的是status的枚举值1/2/3前端要显示待付款/已发货/已完成那 VO 里就该同时有status和statusDesc让前端不用维护一份状态字典。金额字段数据库存的是BigDecimal分前端要展示元VO 里给格式化好的字符串或者明确单位的数值注释写清楚。时间字段统一用yyyy-MM-dd HH:mm:ss格式的字符串或者明确带时区的 ISO 格式别让前端猜。第二类是字段裁剪。列表接口和详情接口必须用不同的 VO。列表页只需要订单号、状态、金额、下单时间详情页才需要收货地址、商品明细、物流轨迹。我见过太多项目用一个OrderVO打天下列表接口返回几百条每条都带着几 KB 的商品详情 JSON响应体轻松上兆前端解析都卡。拆成OrderListVO和OrderDetailVO之后列表接口响应体直接降了一个数量级。第三类是组装逻辑的位置。VO 的组装放在接口层的 Assembler 里不要塞进 Controller 方法体也不要让 Service 参与。Assembler 的职责很纯粹拿 BO 或查询结果补齐字典翻译、格式化、字段映射输出 VO。Component public class OrderVoAssembler { private final DictClient dictClient; public OrderVoAssembler(DictClient dictClient) { this.dictClient dictClient; } public OrderDetailVO toDetailVO(OrderBO bo) { if (bo null) { return null; } OrderDetailVO vo new OrderDetailVO(); vo.setOrderNo(bo.getOrderNo()); vo.setStatus(bo.getStatus().getCode()); vo.setStatusDesc(bo.getStatus().getDesc()); vo.setPayAmount(bo.getPayAmount().setScale(2, RoundingMode.HALF_UP).toPlainString()); vo.setCreateTime(bo.getCreateTime().format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); vo.setItems(bo.getItems().stream().map(this::toItemVO).collect(Collectors.toList())); return vo; } }这里有个性能上的注意点字典翻译不要在循环里逐个查。上面代码里的statusDesc是从枚举直接取的没有远程调用如果状态描述来自配置中心或者数据库字典表一定要先收集所有需要的 key批量查一次再回填否则列表页一百条数据就是一百次远程调用。这个坑我在两个项目里都踩过接口 P99 从 80ms 涨到 1.2s最后定位就是 Assembler 里的循环调用。3.2 QO 的收敛、校验与分页参数QO 最容易出问题的地方不是字段怎么定义是默认值和边界校验。分页参数没有上限前端传个pageSize100000数据库直接被打挂日期区间不校验先后顺序开始时间大于结束时间SQL 查不出数据还被当成 bug 排查半天字符串条件不 trim用户复制粘贴带了个空格模糊查询全表扫描。我的做法是把 QO 当成一个独立的输入契约来处理字段上直接挂校验注解Controller 加Validated边界值在 QO 内部兜底public class OrderPageQO { private static final int MAX_PAGE_SIZE 200; Min(value 1, message 页码不能小于1) private Integer pageNum 1; Min(value 1, message 每页条数不能小于1) private Integer pageSize 20; Size(max 50, message 关键字长度不能超过50) private String keyword; DateTimeFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime startTime; DateTimeFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime endTime; public Integer getPageSize() { if (pageSize null || pageSize 1) { return 20; } return Math.min(pageSize, MAX_PAGE_SIZE); } public String getKeyword() { return keyword null ? null : keyword.trim(); } public LocalDateTime getStartTime() { if (startTime ! null endTime ! null startTime.isAfter(endTime)) { throw new IllegalArgumentException(开始时间不能晚于结束时间); } return startTime; } }pageSize的 getter 里做上限截断看起来有点偷偷改用户输入的意味但这是防御性设计——前端传错了不该让数据库承担后果截断到 200 比直接拒绝请求体验更好日志里记一条 warn 就够了。关键字 trim 也是同理用户从表格里复制的内容经常带首尾空格。还有一个设计决策QO 要不要包含排序字段。我的建议是包含但必须是白名单。orderBy传个任意字符串拼进 SQL那就是注入漏洞维护一个允许排序的字段列表传不在列表里的值就忽略或者报错。这个细节很多团队在项目上线被安全扫描打回来之后才补。3.3 BO 承载业务行为的写法BO 和 VO 最大的区别在于BO 有行为VO 只有数据。如果一个 BO 里全是 getter/setter没有任何方法那它本质上就是个 DTO叫 BO 有点名不副实。举个例子订单状态流转。用贫血模型写逻辑散落在 Service 里public void ship(Long orderId) { OrderPO po orderMapper.selectById(orderId); if (po.getStatus() ! 2) { throw new BizException(只有已支付订单可以发货); } po.setStatus(3); orderMapper.updateById(po); }这种写法的问题是业务规则已支付才能发货这个知识被埋在了某个 Service 方法里。别的入口比如批量发货、运营后台强制发货如果没调用这个方法就没走这条校验。时间一长规则会以复制粘贴的形式散落在五六个地方改一次规则要全局搜索。改成 BO 承载行为public class OrderBO { private Long id; private String orderNo; private OrderStatus status; private BigDecimal payAmount; private LocalDateTime payTime; private ListOrderItemBO items; public void ship(String logisticsNo) { if (this.status ! OrderStatus.PAID) { throw new BizException(只有已支付订单可以发货); } if (StringUtils.isBlank(logisticsNo)) { throw new BizException(物流单号不能为空); } this.status OrderStatus.SHIPPED; } public boolean canRefund() { return this.status OrderStatus.PAID || this.status OrderStatus.SHIPPED; } }规则跟着状态走任何入口只要操作 BO校验都会执行。这就是领域模型的价