资讯动态

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

发布时间:2026/10/31 6:05:15 来源:尧图企业网站定制
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的UserDTOService 里直接传这个 DTOMapper 层再返回一个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 ListString 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()之间只能靠传参协作。而如果有一个OrderBOpublic class OrderBO { private Long buyerId; private ListOrderItemBO 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、JsonIgnoreNotNull、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里放公共分页对象比如PageResultT但别把PageResult塞进 BO 里后面会讲这个坑。3.3 各对象在 Maven 模块间的依赖方向如果项目是多模块的位置问题会升级成模块依赖问题更需要说清楚。核心原则只有一条依赖只能从外向内。模块可依赖禁止依赖webController VO QOapplication、commoninfrastructure、repositoryapplicationService BOdomain、commonwebdomain领域模型、领域服务commonweb、application、infrastructureinfrastructurePO、Mapperdomain、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 ResultLong 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 或ListBOController 里必须调用 Assembler 转成 VO 才能return。为了防住这个我在团队里推了一个检查规则Controller 层出现 BO 类型出现在返回值位置代码评审不通过。顺带说BO 直接序列化给前端还有别的成本循环引用BO 之间互相关联、懒加载代理对象序列化失败、内部枚举暴露。这些问题在小规模测试时都发现不了量一上来就成批出现。5.3 QO 被当成 DTO 复用到 RPC 接口这个坑的排查过程比较绕值得写下来。现象是HTTP 接口新增了一个可选参数加在OrderQueryQO上本地测试正常发到环境后另一个服务通过 RPC 调这个接口开始报反序列化异常。排查过程大概这样先看异常栈报的是Unknown field说明调用方传了一个服务端不认识的字段。但我们刚发版加的是服务端字段方向反了。检查调用方和服务端的版本发现调用方是旧版本服务端是新版本。理论上向后兼容没问题。再看 RPC 框架的序列化配置用的是严格模式多余字段会报错。问题在于服务端返回的响应里带上了新加的字段。追到根源这个QO被同时用作了请求和响应的字段载体当时为了省一个类所以服务端加了字段返回体也变了老版本的调用方解析失败。修复是把 QO 和响应对象拆开RPC 接口单独定义一套不复用 Web 层的类。代价是多写几个类收益是版本兼容问题彻底消失。这件事教会我一个原则跨进程边界的对象必须独立定义哪怕字段一模一样。因为它们的演进节奏不一样复用就是把两个节奏绑在一起。5.4 分页参数塞进 BO有一版代码里OrderBO上带了pageNum和pageSize两个字段理由是查询时方便传。用了不到一个月问题来了某个业务场景需要把查出来的订单列表再处理一遍处理完之后分页信息全乱了因为 BO 上挂着的是查询时的页码而不是结果集的位置。分页本质上是数据访问层的概念属于怎么取不属于是什么。它应该待在 QO 里入参以及 Repository 的方法参数里绝不该进 BO。修复的方式是把分页抽成一个独立的PageQuery对象Repository 接收它返回PageResultTBO 里一个分页字段都不留。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分页信息应该作为PageResultT的一部分嵌在响应里不需要每个接口定义一个。BaseVO一个所有 VO 都继承的基类里面塞createTime、updateTime、creator。问题在于详情页要这些字段列表页不要继承之后列表页不得不返回一堆用不上的字段或者加JsonIgnore各自覆盖越搞越乱。用组合别用继承。CommonBO同上的问题只是发生在 BO 层。BO 之间的复用应该靠组合比如OrderBO里持有一个BuyerBO。我在实际项目里越往后写越倾向于一个结论这些对象的数量可以多一些类可以小一些但一个类绝不能同时服务两种角色。宁可多写二十个小类也别让一个类承担入口和出口两种职责。前者只是代码量问题后者是持续的排查成本。最后分享一个我在带人时常用的判断技巧当你不确定某个对象该放哪的时候问自己一个问题——如果前端明天要改这个接口的返回格式我需要动哪些文件如果答案里出现了domain或repository目录那这个对象的实际位置就已经越界了不管它物理上放在哪个文件夹里。位置是表象依赖方向才是本质把依赖方向守住包名怎么起都不会出大问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑