Spring分层架构中VO/QO/BO包结构设计与依赖方向实践

发布时间:2026/10/31 6:05:02
Spring分层架构中VO/QO/BO包结构设计与依赖方向实践

1. 一次代码评审里的争执:这个 UserVO 到底该放在哪个包

上周帮一个团队过代码,评审到一半,两个人在UserVO的归属上吵了二十分钟。一方说应该放在controller/vo下面,理由是"只有 Controller 会用到它";另一方坚持放在model/vo,理由是"万一 RPC 接口也要复用呢"。最后翻出项目历史提交记录一看,这个类过去半年被移动过四次,从dto到vo再到model,路径越改越深,谁也没说服谁。这种场景我见过太多次了。vo、qo、bo在 Spring 项目里的位置问题,表面看是"建包放文件"的小事,实际上是整个分层架构里最容易被反复推翻、最容易产生技术债的一块地基。地基歪一点,上面就会长出 Controller 直接返回数据库实体、Service 层混着前端展示字段这种畸形结构。

这篇东西想聊的就是这几个对象到底该待在哪、为什么待在那、以及待错位置之后会付出什么代价。VO是 View Object,暴露给前端的展示对象;QO是 Query Object,承接外部查询和提交参数的入参对象;BO是 Business Object,服务层内部承载业务规则和状态的对象。三个字母背后是三种完全不同的数据视角:一个是"给别人看",一个是"听别人问",一个是"给自己算"。Spring 本身对包结构没有任何强制约束,@Component扫到哪个目录不是扫,所以框架不会拦着你犯错,这也正是这个问题反复被讨论的原因。适合正在搭项目骨架、正被分层搞得头大的后端同学,也适合已经写了几年但对"为什么这么分层"还停留在背概念阶段的老手。

1.1 为什么"放哪"这个问题会反复出现

根源在于多数项目是在业务压力下长出来的,不是设计出来的。第一版通常简单粗暴:Controller 里接一个@RequestBody的UserDTO,Service 里直接传这个 DTO,Mapper 层再返回一个User实体,三层之间对象来回传,字段全都不变。这个阶段跑得飞快,没人觉得有问题。等到前端要加一个"余额脱敏显示"、等到运营要加一个"按注册渠道筛选"、等到第三方对接要一个精简版用户信息,问题就来了:那个万能的UserDTO上挂满了各种注解,@NotNull是给创建接口写的,@JsonIgnore是给展示接口写的,@Schema描述互相打架,改一个字段要先确认会不会影响另外五个调用方。

我见过一个更典型的例子。某个订单查询接口的入参类叫OrderQueryDTO,被同时用在三个地方:前端列表页查询、内部运营后台导出、另一个服务通过 HTTP 调用。后来源头那边加了一个必填字段做灰度,前端直接报 400,原因是内部调用方没传。改起来其实不难,但只要对象只有一份,每一次修改都是全链路回归。这就是位置问题的本质:对象放在哪一层,决定了它被谁依赖、被修改时谁受牵连。位置不是审美问题,是依赖关系问题。

1.2 三种典型的分层现状与它们的代价

我在各种项目里见过三种比较有代表性的摆放方式,代价各不相同。

第一种是"全塞 DTO 包"。所有对象都叫XxxDTO,统一放在com.xxx.dto下,Controller、Service、Mapper 全用它。好处是省事,坏处是依赖方向彻底糊掉,领域层反过来依赖了接口层的入参结构,前端想加个字段,领域模型就得跟着动。这种项目的典型症状是:Service 里有大量if (dto.getXxx() != null)的判断,因为入参校验的活儿漏到了业务层。

第二种是"按层建包"。controller/vo、controller/qo、service/bo、dao/po分得清清楚楚,依赖方向明确。这是多数团队演进后的形态,比较稳。缺点是当一个业务域的类多起来之后,controller包会膨胀到上百个文件,按业务找代码会比较费劲。

第三种是"按域内聚"。每个业务模块内部自带interfaces/vo、application/bo、domain、infrastructure/po,模块之间用接口或事件通信。这种结构在业务复杂、团队人多的时候优势明显,代价是需要团队有比较统一的分层意识,否则每个人各写一套,反而更乱。

这三种没有绝对优劣。哪种都行,关键是同一个项目里只能选一种并且坚持到底,最怕的是混着来:一半人在controller/vo,一半人在model/vo,半年后没人说得清哪个是标准。

2. 先分清职责,再谈位置:VO/QO/BO 的本质差异

位置问题的争论,八成是因为前置问题没解决——大家嘴里的"VO"根本不是同一个东西。有的人说 VO 就是"返回给前端的东西",有的人说 VO 可以带业务方法,还有人把 DTO 和 VO 当同义词用。名词不统一,位置当然吵不出结果。所以先把这三个对象的职责边界钉死,位置自然就浮出来了。

2.1 VO:为"看"而生的裁剪视图

VO 的唯一使命是"给外面看"。它的字段应该按展示需求来组织,而不是按数据库表结构来组织。举个具体的:订单表里有order_no、status(枚举或数字)、buyer_id、pay_amount、create_time,前端页面要的可能是:

@Data public class OrderDetailVO { private String orderNo; private String statusText; // 状态的中文描述,不是枚举 private String buyerNickname; // 需要跨表聚合,DB 里没有 private BigDecimal payAmount; // 分转元后的金额 private String createTime; // 格式化好的字符串 }

注意几个特征:status变成了statusText,这是展示转换;buyer_id变成了buyerNickname,这是一次跨聚合的组装;create_time从时间类型变成了字符串,这是格式化。这些都是"看"的需要,和"存"的需要已经没关系了。所以 VO 上出现@TableField、@Column这类持久化注解,或者出现@Id,基本可以判定它被用错了位置——它被当成了实体在用。

VO 还有一个常被忽略的特性:它是可以有很多个的。同一个实体,列表页要一个精简版OrderListItemVO(只含订单号、金额、下单时间),详情页要一个完整版OrderDetailVO,导出功能要一个扁平版OrderExportVO。这不是重复代码,这是正常的视图分化。一个接口只返回一个"大而全"的 VO,字段里一半是 null,是很多项目响应体积膨胀的主因。

至于它该放哪,结论很直接:VO 属于接口层。因为只有接口层知道外部需要看到什么。如果放在 domain 或 model 里,就意味着领域层开始关心"前端想看什么",这是依赖倒置。

2.2 QO:为"问"而生的入参契约

QO 承接的是外部输入,职责是"把不信任的参数安全地转换成内部的查询意图"。它身上通常挂三类东西:校验注解、格式转换注解、分页/排序参数。

@Data public class OrderQueryQO { @NotNull(message = "tenantId 不能为空") private Long tenantId; @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate startDate; private LocalDate endDate; @Min(1) private Integer pageNum = 1; @Max(200) private Integer pageSize = 20; private List<String> statusList; }

这里有几个刻意的设计点。pageNum和pageSize只属于入参,不属于业务对象,因为分页是"取数据的方式",不是"数据本身的属性";tenantId用@NotNull强制要求,因为这是多租户隔离的底线,必须在进入业务逻辑前就被拦住;statusList用的是字符串而不是枚举,因为前端传枚举值的方式不统一,让 QO 承担容错转换的活,比让领域层去猜要好。

QO 和 VO 一样,也属于接口层,但方向相反:VO 是出口,QO 是入口。很多人问 QO 和 DTO 什么关系,我的看法是:DTO 是一个泛称,用于跨进程传输;QO 是 DTO 在"查询入口"这个场景下的具体化。如果你的 QO 要被另一个服务通过 RPC 调用,那它同时也就成了 DTO,但这不改变它待的位置——它依然在外层,只不过外层从 HTTP 扩展到了 RPC。

2.3 BO:为"算"而生的业务聚合体

BO 是三个里最容易被误解的。很多人以为"Service 里用的那个对象就是 BO",然后就把 Controller 传下来的 QO 直接丢进 Service,改个名叫 BO。这不算错,但失去了 BO 的意义。

我用一个实际场景说明区别。用户下单需要:校验库存、计算优惠、锁定优惠券、生成订单号、写订单主表和明细表。如果 Service 收到的只是一个OrderCreateQO(含商品 ID 列表、优惠券 ID、收货地址),那这些业务动作的中间状态就得散落在局部变量里,方法的参数会膨胀到七八个,preCheck()、calcAmount()、buildOrder()之间只能靠传参协作。而如果有一个OrderBO:

public class OrderBO { private Long buyerId; private List<OrderItemBO> items; private Long couponId; private BigDecimal originAmount; private BigDecimal discountAmount; private BigDecimal payAmount; private OrderStatus status; public boolean isPayable() { return status == OrderStatus.CREATED && payAmount.compareTo(BigDecimal.ZERO) > 0; } public void applyDiscount(BigDecimal discount) { // 折扣规则、边界保护都写在这里 } }

业务状态和行为被收拢到了对象内部,Service 变成编排者而不是计算器。这才是 BO 的真正价值:它承载的是"业务上如何变化",而不是"数据长什么样"。所以 BO 该放在应用层或领域层,而不是接口层。它的字段是业务概念,可能会和 PO 不一样(比如items是聚合出来的),也可能会和 VO 不一样(比如没有statusText)。

2.4 一张对照表把边界钉死

光说容易绕,直接上表:

维度VOQOBO
中文含义视图对象查询/入参对象业务对象
数据流向出(后端到前端)入(前端到后端)内(层与层之间)
所属层接口层接口层应用层/领域层
字段来源展示需求外部传参业务规则
典型注解@JsonFormat、@JsonIgnore@NotNull、@Min、@DateTimeFormat无框架注解,可能带业务方法
是否含方法一般不含一般不含通常含
可以有几个按场景多个按接口多个按聚合一个
是否允许复用不建议跨接口复用不建议跨接口复用域内复用是正常的

我自己判断一个类放错位置有个土办法:看它身上的注解。如果业务对象上出现了@JsonFormat、@NotNull、@TableField这类来自展示层或持久层的注解,那它八成站错了队。注解是依赖的具象化,注解越界,依赖就越界。

3. 我的包结构落地方式:按层还是按域

职责讲清楚之后,位置就有依据了。这一节直接给可执行的目录结构,两种路线都写,你按团队规模选。

3.1 包结构的两条路线:技术分层 vs 领域内聚

技术分层是大多数 Spring 项目的默认形态,特点是"先按技术职责切,再按业务切":

com.example.order ├── controller │ ├── OrderController.java │ ├── vo │ │ ├── OrderDetailVO.java │ │ └── OrderListItemVO.java │ └── qo │ ├── OrderQueryQO.java │ └── OrderCreateQO.java ├── service │ ├── OrderService.java │ ├── impl │ │ └── OrderServiceImpl.java │ └── bo │ ├── OrderBO.java │ └── OrderItemBO.java ├── convert │ ├── OrderVoConverter.java │ └── OrderBoConverter.java └── repository ├── OrderRepository.java └── po └── OrderPO.java

这种结构的优点是"按职责找代码"很快,新人进来一眼能看懂。缺点是业务域一多,controller/vo会变成一个大杂烩,同一个功能涉及订单和用户,得在四个目录之间跳。

领域内聚的版本长这样:

com.example.order ├── interfaces │ ├── web │ │ ├── OrderController.java │ │ ├── vo │ │ └── qo │ └── rpc ├── application │ ├── OrderAppService.java │ └── bo ├── domain │ ├── OrderDomainService.java │ └── model ├── infrastructure │ ├── persistence │ │ ├── OrderRepositoryImpl.java │ │ └── po │ └── convert └── common

它的好处是每个业务域自包含,改一个功能基本不出这个包。代价是包层级深,需要团队有共识。我的经验是:5 人以下、业务不超过 5 个域的项目,用技术分层;人数再多或者域再多,就往领域内聚走。中途切换成本很高,所以这个决定要在项目早期做,别拖到代码量上万行再改。

3.2 具体目录树与放置规则

不管选哪种,有几条放置规则是共通的,我列一下:

  • VO和QO必须放在入口层(controller或interfaces)的子包里,不要和业务类混在一起。理由是它们会随外部接口变化而变化,变化频率和业务类完全不同,放一起会让 git diff 噪音很大。
  • BO放在service/application或domain下,和业务服务在一起。它和 Service 的生命周期是一致的。
  • PO(持久化对象,也叫 DO、Entity)必须放在repository/infrastructure/persistence下,和 Mapper 相邻。它和表结构绑定,改动频率跟 DDL 同步。
  • 转换器单独建包(convert或mapper),不要塞在 Service 实现类里。转换逻辑一多,Service 会被淹没。
  • 在common或base里放公共分页对象,比如PageResult<T>,但别把PageResult塞进 BO 里(后面会讲这个坑)。

3.3 各对象在 Maven 模块间的依赖方向

如果项目是多模块的,位置问题会升级成"模块依赖问题",更需要说清楚。核心原则只有一条:依赖只能从外向内。

模块可依赖禁止依赖
web(Controller + VO + QO)application、commoninfrastructure、repository
application(Service + BO)domain、commonweb
domain(领域模型、领域服务)commonweb、application、infrastructure
infrastructure(PO、Mapper)domain、commonweb

我见过最典型的越界是:domain模块里留了一个UserVO的引用,只因为某个领域服务想直接返回它方便。这一处的成本是,前端需求一改,领域模块就得跟着发版,编译依赖彻底反了。防这个最有效的办法不是靠自觉,是在application模块的pom.xml里拒绝依赖web模块,让 Maven 帮你拦住。

4. 转换链路:从 Controller 进来的参数怎么一路变成 BO

知道了谁在哪,下一个问题就是它们之间怎么流动。这条链路的设计质量,直接决定了后期改需求时的痛苦指数。

4.1 入站链路:QO → 查询条件 → BO

写操作的链路一般是:Controller 接QO,做一层转换生成BO,交给 Service 处理。读操作的链路是:Controller 接QO,转换成一个查询条件对象(有时候直接叫XxxQuery,是 BO 的一个轻量形态),交给 Repository 查询。

关键点在于:QO 不应该原样往下传。原因有两层。第一层是参数校验,QO 上的校验注解只在 Controller 层生效(前提是方法参数加了@Valid),往下传就没有这层保护了。第二层是字段对齐,QO 里可能有外面传进来但不该进业务层的字段,比如pageNum、pageSize、orderBy,这些在转换时应该被剥掉。

@PostMapping("/orders") public Result<Long> create(@Valid @RequestBody OrderCreateQO qo) { OrderBO bo = OrderBoConverter.INSTANCE.fromCreateQO(qo); return Result.success(orderService.create(bo)); }

转换这一步我在实际项目里会用工整度换一点性能:读接口手写转换,写接口用 MapStruct。原因很实在,读接口的 VO 组装逻辑经常涉及多表拼装,手写更灵活;写接口的字段对应关系简单,MapStruct 编译期生成,省手写又不会漏字段。BeanUtils.copyProperties我是能不用就不用,它是基于反射的,字段名不匹配时会静默跳过,最后变成"少更新了一个字段"这种极难排查的问题。

4.2 出站链路:BO → VO

出站链路比入站更容易出问题,因为这里的转换往往是"多对一":一个 VO 可能是几个 BO 拼出来的。比如订单详情页,OrderDetailVO需要订单 BO、买家 BO、物流 BO 三部分信息。

public OrderDetailVO detail(@RequestParam Long orderId) { OrderBO order = orderService.getById(orderId); BuyerBO buyer = buyerService.getById(order.getBuyerId()); LogisticsBO logistics = logisticsService.getByOrderId(orderId); return OrderVoAssembler.assemble(order, buyer, logistics); }

注意这里的OrderVoAssembler是放在convert包里的,不是 Service 里。把它抽出来的好处是组装逻辑可以单独写单测,而且多个接口可以复用一个装配器。

Converter和Assembler的区别我在项目里会明确区分:Converter 是"一对一字段映射",Assembler 是"多对象聚合,可能带业务判断"(比如status转statusText、金额分转元、时间格式化)。混在一起写的后果是单测很难做,因为聚合装配里混着展示格式化,测试数据准备成本翻倍。

4.3 转换工具的取舍:手写、MapStruct、BeanUtils

这三种我在不同项目里都用过,感受如下:

方式性能漏字段排查复杂映射支持适用场景
手写 setter最高编译期就能发现完全自由读接口、复杂装配
MapStruct高(编译期生成)会有 warning 提示支持自定义方法写接口、字段规整的映射
BeanUtils低(反射)静默失败,极难排查几乎不支持应急、一次性脚本

MapStruct 的具体写法不复杂,声明一个接口加@Mapper就行:

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR) public interface OrderBoConverter { OrderBO fromCreateQO(OrderCreateQO qo); OrderListItemVO toListItemVO(OrderBO bo); }

这里unmappedTargetPolicy = ReportingPolicy.ERROR是关键。默认策略只是 warn,字段漏了照样编译通过,上线才发现某个字段永远为 null。设成 ERROR 之后,漏映射直接构建失败,把问题提前到编译期。这个小配置我建议所有项目都加上,它拦下来的问题比很多单测都多。

5. 几个让我改过架构的真实坑

章节名之所以写"让我改过架构",是因为下面这几个坑都是我在实际项目里踩过并且真的动手调整过目录结构的,不是纸上推演。

5.1 VO 加了个 @NotNull 让整个接口 500

这个坑很经典。某次为了复用,把查询接口的OrderQueryQO直接改了个名当 VO 用,返回给前端。后来有人在里面某个字段上加了@NotNull,因为另一个接口需要这个校验。结果查询接口本身没问题,但某个下游服务解析响应时按 schema 校验,字段为 null 就报错,整个链路 500。

根因是 QO 和 VO 混用。QO 上的校验注解是为了"拒绝非法输入",VO 上的字段是为了"展示尽可能完整",两者的取值哲学完全相反。QO 可以要求必填,VO 永远应该允许 null。混用一个类,两边的要求必然打架。

修复方式是把它们拆开,各自独立。拆完之后那两个类的代码量都不大,但语义清晰了,后续谁也不会误加注解。

一个简单的自查方法:如果一个类既被@RequestBody修饰的入参用过,又被@ResponseBody的返回值用过,那它一定放错了位置。

5.2 BO 泄漏到 Controller 之后

有段时间为了省事,Controller 直接把 Service 返回的OrderBO丢给前端。一开始没问题,直到某天有人在场里加了个内部字段innerCostAmount(内部成本价),忘记加@JsonIgnore,第二天就出了事故。

这件事之后我把规则改成硬性的:Controller 的返回值类型必须是 VO,不允许是 BO。实现上也很简单,Service 层的方法签名统一返回 BO 或List<BO>,Controller 里必须调用 Assembler 转成 VO 才能return。为了防住这个,我在团队里推了一个检查规则:Controller 层出现 BO 类型出现在返回值位置,代码评审不通过。

顺带说,BO 直接序列化给前端还有别的成本:循环引用(BO 之间互相关联)、懒加载代理对象序列化失败、内部枚举暴露。这些问题在小规模测试时都发现不了,量一上来就成批出现。

5.3 QO 被当成 DTO 复用到 RPC 接口

这个坑的排查过程比较绕,值得写下来。现象是:HTTP 接口新增了一个可选参数,加在OrderQueryQO上,本地测试正常,发到环境后,另一个服务通过 RPC 调这个接口开始报反序列化异常。

排查过程大概这样:

  1. 先看异常栈,报的是Unknown field,说明调用方传了一个服务端不认识的字段。但我们刚发版加的是服务端字段,方向反了。
  2. 检查调用方和服务端的版本,发现调用方是旧版本,服务端是新版本。理论上向后兼容没问题。
  3. 再看 RPC 框架的序列化配置,用的是严格模式,多余字段会报错。问题在于服务端返回的响应里带上了新加的字段。
  4. 追到根源:这个QO被同时用作了请求和响应的字段载体(当时为了省一个类),所以服务端加了字段,返回体也变了,老版本的调用方解析失败。

修复是把 QO 和响应对象拆开,RPC 接口单独定义一套,不复用 Web 层的类。代价是多写几个类,收益是版本兼容问题彻底消失。这件事教会我一个原则:跨进程边界的对象,必须独立定义,哪怕字段一模一样。因为它们的演进节奏不一样,复用就是把两个节奏绑在一起。

5.4 分页参数塞进 BO

有一版代码里,OrderBO上带了pageNum和pageSize两个字段,理由是"查询时方便传"。用了不到一个月,问题来了:某个业务场景需要把查出来的订单列表再处理一遍,处理完之后分页信息全乱了,因为 BO 上挂着的是"查询时的页码",而不是"结果集的位置"。

分页本质上是数据访问层的概念,属于"怎么取",不属于"是什么"。它应该待在 QO 里(入参),以及 Repository 的方法参数里,绝不该进 BO。修复的方式是把分页抽成一个独立的PageQuery对象,Repository 接收它,返回PageResult<T>,BO 里一个分页字段都不留。

6. 边界场景:多模块、对外接口、读写分离下的位置微调

前面讲的都是单体项目的标准形态。但实际项目里总有边界情况,位置需要微调,这一节说几种。

6.1 同一个实体在不同微服务里的对象怎么摆

跨服务调用的时候,有个常见纠结:A 服务要调用 B 服务的用户查询接口,那 A 服务里要不要也建一个UserVO?我的做法是:在 A 服务的入口层建一个UserClientVO(或者叫UserViewDTO),专门表达"我从外部拿到的用户信息",不要和 A 服务自己的UserVO混在一起。

原因在于,外部接口返回的数据结构不受你控制,B 服务加个字段、改个类型,你都得跟着改。把它独立出来,等于在你的系统边界上做了一层防腐,B 服务怎么变,你只需要改一处转换。这类对象的位置,我一般放在infrastructure/client或者interfaces/client下,和具体的 Client 类相邻。

6.2 CQRS 场景下 QO 的归属

读写分离或者 CQRS 的架构里,查询侧和写入侧的对象边界会更清晰。写侧接收命令对象(Command),读侧接收查询对象(Query)。这两个的角色其实约等于 QO 的分裂版本,位置同样在接口层。

这里有个细节值得注意:读侧的返回值往往是"视图模型"的集合,和 VO 的职责高度重叠,很多团队会合并成一个类。合并是合理的,但要注意命名一致性——别一半叫XxxVO,一半叫XxxView。团队内部统一约定一种命名,比选哪种命名重要得多。

6.3 那些永远不该成为独立对象的"伪对象"

最后说几个我见过被滥用成独立类的角色:

  • PageVO:分页信息应该作为PageResult<T>的一部分嵌在响应里,不需要每个接口定义一个。
  • BaseVO:一个所有 VO 都继承的基类,里面塞createTime、updateTime、creator。问题在于详情页要这些字段,列表页不要,继承之后列表页不得不返回一堆用不上的字段,或者加@JsonIgnore各自覆盖,越搞越乱。用组合,别用继承。
  • CommonBO:同上的问题,只是发生在 BO 层。BO 之间的复用应该靠组合,比如OrderBO里持有一个BuyerBO。

我在实际项目里越往后写越倾向于一个结论:这些对象的数量可以多一些,类可以小一些,但一个类绝不能同时服务两种角色。宁可多写二十个小类,也别让一个类承担入口和出口两种职责。前者只是代码量问题,后者是持续的排查成本。

最后分享一个我在带人时常用的判断技巧:当你不确定某个对象该放哪的时候,问自己一个问题——"如果前端明天要改这个接口的返回格式,我需要动哪些文件?"如果答案里出现了domain或repository目录,那这个对象的实际位置就已经越界了,不管它物理上放在哪个文件夹里。位置是表象,依赖方向才是本质,把依赖方向守住,包名怎么起都不会出大问题。