
1. 先把 vo、qo、bo 摆到分层架构的“楼层平面图”上vo、qo、bo 在 spring 项目里的位置几乎是我 review 别人代码时最先看的一件事。原因很简单这三个对象的包路径一旦放错后面的依赖方向、参数校验、字段泄露、序列化问题会像多米诺骨牌一样连锁反应。我见过太多项目vo和entity混在一个model包里qo直接继承DObo从 controller 一路裸奔到 mapper最后改一个字段要在五六个文件里搜索替换。这篇文章就把这三个对象的位置问题彻底讲透它们该放在哪个包、归哪一层管、谁来创建、谁来消费、谁不该碰它们。先说清楚它们各自是什么避免概念打架。**voView Object**是面向前端展示的视图对象字段按页面需要裁剪可以带格式化、脱敏、字典翻译**qoQuery Object**是查询入参对象把散落在方法签名里的七八个参数收拢成一个对象天然适合承载分页、排序、模糊匹配条件**boBusiness Object**是业务对象服务于 service 层的业务逻辑编排它的字段是业务语义而不是表结构语义可能是多张表拼装、计算、聚合之后的结果。这三个东西的共同点是都不是数据库表的映射都不该带 ORM 注解。很多人问“位置”的时候其实问的是三件不同的事。第一是物理位置哪个 maven 模块、哪个 package。第二是逻辑位置哪一层负责创建、哪一层负责消费、依赖箭头朝哪边。第三是生命周期位置什么时候被 new 出来、什么时候被丢弃、能不能跨层传递。这三件事必须一起回答只回答包名是不够的。比如你把vo放在com.xxx.web.vo下但 service 层直接返回vo那物理位置对了逻辑位置还是错的前端字段一改service 就得跟着改分层等于白做。我判断一个项目的分层是否健康有个很土但很准的办法看 import 语句。打开任意一个 service 实现类如果它 import 了web.vo或者web.qo基本可以判定这层被穿透了打开任意一个 controller如果它 import 了dao.entity那说明 DO 已经泄漏到接口层表结构变更会直接冲击 API 契约。这个检查不需要跑起来扫一眼 import 就能定位问题比看架构图靠谱得多。下面这张表是我给团队定的基线后面所有章节都围绕它展开。对象建议包路径创建者消费者允许带的注解voweb/vo或api/vocontroller / 转换器前端、HTTP 序列化序列化、文档、格式化注解qoweb/qo或api/qo框架反序列化controller、service 入参参数校验、文档注解boservice/boserviceservice 内部、转换器业务语义注解极少dtoapi/dto调用方 / 提供方RPC 接口出入参序列化、校验、文档DO/POdao/entityORM 框架mapper、repositoryORM 注解2. 逐个对象落地包建在哪、类怎么写、边界在哪2.1 vo 的位置贴着接口层永远向内看vo 我固定放在接口层路径写成com.xxx.web.vo如果用多模块就是xxx-api模块下的vo子包。理由是 vo 的字段定义完全由“页面要展示什么”决定它属于对外契约的一部分契约就该和接口定义待在一起。一个订单列表页要展示订单号、下单时间、实付金额、状态文案、商品缩略图这些字段和数据库里的order表没有一一对应关系——状态是数字还是文案、金额是元还是分、时间是时间戳还是格式化字符串全看前端怎么用。正因为如此vo 里出现任何 ORM 注解都是危险信号。我见过有人图省事在 DO 上加JsonFormat然后直接返回给前端短期确实少写一个类但代价是前端要一个statusText你就得往 DO 里加字段加完发现这个字段在表里不存在ORM 启动时校验报错于是加Transient再后来发现Transient又被某个序列化框架忽略……绕一大圈还是得老老实实建 vo。这笔账我算过很多次早建早省事。package com.demo.web.vo; public class OrderVO { private String orderNo; private String statusText; // 已翻译的状态文案非数据库字段 private String payAmount; // 已格式化前端直接渲染 private String createTime; // 统一时区格式化后的字符串 private ListOrderItemVO items; // getter / setter 略 }代码里这些字段一眼就能看出是“给前端看的”statusText是字典翻译结果payAmount是字符串而不是BigDecimalcreateTime是已经格式化的串。如果你把BigDecimal直接丢给前端JavaScript 的浮点数精度问题会在金额上给你上一课我踩过19.99 变成 19.989999999999998 的那种。所以 vo 的字段类型选择本身就是一层“适配”工作这个适配职责只有 vo 能承担DO 承担不了也不该承担。还有一个细节值得说vo 不要设计成万能对象。有些团队搞一个BaseVO塞进 id、createBy、updateBy、remark 这些通用字段然后所有 vo 继承它。结果前端拿到的响应里永远带一堆用不上的审计字段响应体积变大不说还容易把内部操作人信息暴露出去。我的做法是vo 该有几个字段就写几个字段宁可重复不要继承。重复十行代码的成本远低于泄漏一个字段带来的排查成本。2.2 qo 的位置入口收口把校验前移qo 同样放在接口层com.xxx.web.qo。它的核心价值是把 controller 方法签名从“参数列表”变成“一个对象”从而让参数校验、分页、排序这些横切关注点有地方安放。比如一个订单查询接口原始写法可能是list(String keyword, Integer status, LocalDate start, LocalDate end, int page, int size)六个参数调用方少传一个就编译不过加一个筛选项全链路改签名。换成 qo 之后方法签名稳定成list(OrderQueryQO qo)新增筛选项只改 qo 内部。qo 最关键的一条纪律不要继承 DO也不要让 qo 里出现id、createTime、updateBy这类字段。这条纪律不是洁癖是安全边界。我排查过一次越权问题根源就是查询对象直接复用了实体类前端在请求体里多塞了一个status0而业务代码本意是只查“已支付”的订单结果把“待支付”的也吐出来了。查询条件由谁控制必须是明确的面向用户的筛选字段放 qo系统内部强制的条件租户 id、逻辑删除标记、数据权限在 service 里硬编码追加绝不给前端传入口子。package com.demo.web.qo; import javax.validation.constraints.Max; import javax.validation.constraints.Min; import javax.validation.constraints.Size; public class OrderQueryQO { Size(max 32, message 关键词长度不能超过32) private String keyword; Min(value 0, message 状态取值非法) Max(value 5, message 状态取值非法) private Integer status; private String startDate; private String endDate; Min(1) private Integer pageNo 1; Min(1) Max(200) private Integer pageSize 20; // getter / setter 略 }这段代码里有两处刻意设计。一处是pageSize加了上限 200防止有人传 100000 把数据库拖死这个坑我在压测时遇到过一个没加上限的接口把连接池占满连带整个应用雪崩。另一处是startDate、endDate用String而不是LocalDate原因是前端传的日期格式五花八门用字符串接住、在转换层统一解析比让框架反序列化失败抛 400 要友好得多也方便做“开始日期为空则默认近 30 天”这类兜底逻辑。另外补充一个我常用的做法把分页字段抽成PageQO基类所有具体 qo 继承它。这个继承是安全的因为分页字段本身就是纯粹的技术参数不涉及业务字段污染。但注意别把orderBy字段做成自由字符串直接拼进 SQL那等于把 SQL 注入的口子开在 qo 上。要排序就用枚举白名单映射。2.3 bo 的位置service 层的内务不出门bo 放在com.xxx.service.bo它是三个对象里唯一“对内”的。为什么强调对内因为 bo 承载的是业务编排的中间结果字段往往不完整、不规范、随时会变。比如下单场景的OrderCreateBO可能包含商品快照列表、优惠券抵扣明细、运费计算结果、风控标记这些东西拼装完之后要落库成三张表但没有任何一个前端页面需要看到它的全貌。如果 bo 泄漏到 controller你就被迫给它加序列化注解、加文档注解、加兼容性考虑一个纯内部对象瞬间背上对外契约的包袱。我见过最典型的反例是service 方法直接返回 bocontroller 原样return出去。表面看少写一个 vo实际后果是前端能拿到costPrice成本价、supplierId供应商 id、riskFlag风控标记这些内部字段。这类事故在电商、金融场景里非常致命。正确的做法是 service 返回 bocontroller 层的转换器把它翻译成 vo翻译过程本身就是一次“字段白名单”过滤。package com.demo.service.bo; public class OrderDetailBO { private Long orderId; private String orderNo; private Integer status; // 原始状态码翻译交给上层 private BigDecimal payAmount; // 原始金额格式化交给上层 private BigDecimal costAmount; // 内部字段绝不出现在 vo private ListOrderItemBO items; private String riskLevel; // 内部风控标记 // getter / setter 略 }注意 bo 里我保留了status的原始码而不是文案。原因是翻译需要的字典数据可能在缓存里也可能在数据库里属于“展示关注点”放在 web 层做更合适同时 service 内部判断业务分支时用码值比用文案可靠得多。这条经验是我被“状态文案改了一个字导致业务判断失效”坑过之后总结出来的能用码值判断的地方绝不用文案判断。还有一类 bo 值得单独提聚合 bo。当一个接口需要跨多个微服务或多个领域取数时我会在 service 层建一个XxxAggregateBO把各来源的数据拼在一起供上层一次性消费。这类对象生命周期极短方法内创建、方法内销毁连字段的 setter 都可以省掉直接用构造器注入。位置不变还是service/bo但可以考虑放在service/bo/aggregate子包里和持久化实体的 bo 区分开。2.4 DO、DTO 的补充定位避免边界模糊既然聊到位置就得把容易混淆的 DO 和 DTO 一并钉死。DOData Object/ POPersistent Object放在com.xxx.dao.entity与表结构一一对应带 ORM 注解只在 dao 层和 service 层之间流转。它是三个对象之外唯一允许带持久化注解的类。很多人把 DO 叫 entity叫什么都行关键是位置固定、职责固定它是数据库的镜像不是业务的表达。DTOData Transfer Object放在api/dto专门用于跨进程传输——RPC 接口的出入参、消息队列的消息体、跨模块调用的契约。DTO 和 vo 的区别在于消费方vo 给前端浏览器或 AppDTO 给另一个服务。所以 DTO 要考虑版本兼容、字段只增不删、默认值兜底而 vo 可以考虑脱敏、格式化、国际化。这两个不能混用我见过把 vo 直接当 RPC 出入参的结果前端要加个展示字段硬生生把 RPC 契约改了下游服务全部要跟着升级一个字段引发一次发布事故。到这里五个对象的物理位置和职责边界都清楚了。再用一句话收束越靠外的对象越面向消费方越靠内的对象越贴近存储中间用转换器过渡任何两个非相邻层之间不允许直接共享对象。这句话听起来像教条但它能挡掉八成以上的分层事故。3. 转换链条怎么设计别让对象在层间裸奔3.1 转换器放哪一层决定了依赖方向对象分好包之后紧接着的问题就是“谁来转”。我的原则很硬转换器放在消费方所在的层紧邻使用点。具体来说qo 转查询条件、bo 转 vo 这两类转换放在 web 层DO 转 bo 放在 service 层。这样做的原因是依赖方向转换器引用了谁就意味着谁被依赖把转换器放在消费方依赖箭头永远指向内层不会出现内层反向依赖外层的情况。具体目录我会这样摆com.xxx.web.convert放OrderVoConvertercom.xxx.service.convert放OrderBoConverter。命名上统一用XxxConverter方法名统一成toVo、toBo、toBoList一眼能看出方向。团队里如果有人写出OrderConverter.doToVo这种跨两层的转换方法说明他要么是想省事要么是没理清层。跨两层的转换应该拆成两段DO → BO 在 serviceBO → VO 在 web中间用 bo 衔接。多写一个类换来的是每层只关心自己相邻的对象。有人会反驳说这样类太多。我实测过一个中等规模的订单模块五个对象、四个转换器代码量大概多出三百行但换来的是改表结构只影响 DO 和 DO→BO 的转换改前端字段只影响 VO 和 BO→VO 的转换改业务口径只影响 BO 和 service 逻辑。三件事互不干扰改动范围可预测。相比之下用一个大而全的转换工具到处反射拷贝的写法出问题时你根本不知道哪个字段在哪个环节被覆盖了。3.2 手写、MapStruct、反射工具三种方案的取舍转换实现我一般只在两种方案里选手写 setter 和 MapStruct。反射型工具各种BeanUtil.copyProperties我基本不用在核心链路上理由很实在字段名一改编译期毫无提示运行时静默丢字段排查成本极高。我遇到过最难受的一次线上问题就是某个 vo 的字段名从mobile改成phone反射拷贝那边没跟着改接口返回 null前端显示空白测试没覆盖到用户投诉才发现。MapStruct 的好处是编译期生成实现类字段对不上会给出警告或报错IDE 里能直接跳转到生成的代码。代价是要注意注解处理器的顺序尤其是和 Lombok 共存时如果lombok的 processor 排在mapstruct后面生成的实现类里会找不到 getter编译报一堆莫名其妙的错。这个坑我踩过解决办法是在构建配置里把lombok-mapstruct-binding加上并确保 processor 顺序正确。手写转换适合字段少、逻辑复杂的场景。比如 bo 转 vo 时要根据状态码翻译文案、根据金额做单位换算、根据权限决定某个字段是否置空这些逻辑用映射注解表达会非常别扭不如老老实实写十几行 setter可读性反而更好。我的判断标准很简单纯字段搬运用 MapStruct带条件分支或外部查询的用手写。两者在同一个项目里共存完全没问题。注意不管用哪种方案转换方法里都不要塞业务查询。我见过在转换器里注入 mapper 去查字典表的写法转一次列表触发 N 次查询接口响应从 50ms 涨到 2s。字典翻译这类操作应该在 service 里批量查好、组装成 map 传进来。3.3 校验注解该打在谁身上这件事必须一次说清参数校验注解的位置是 vo/qo/bo 三者边界里最容易搞混的地方。我的规则只有一条校验注解只打在“外部输入”的对象上也就是 qo 和 dtobo 和 DO 一律不带校验注解。原因在于校验的语义是“拒绝不可信输入”而 bo 是内部已经处理过的中间结果对它做校验说明前面的处理环节出了问题这时候应该抛业务异常而不是参数校验异常。具体到代码controller 方法上写Valid RequestBody OrderQueryQO qoqo 内部用NotNull、Size、Min这些注解声明约束。如果同一个 qo 要同时服务“新增”和“修改”两个接口用Validated的分组功能区分新增时校验商品 id 必填修改时校验订单号必填。分组接口定义在web/validation包下比如CreateGroup、UpdateGroup保持和 qo 同层。这里有个容易被忽略的细节嵌套对象的校验要加Valid。qo 里如果有一个ListOrderItemQO items只在外层加Valid是不够的内层集合元素的字段约束不会生效必须在字段上再标一个Valid。这个坑很隐蔽因为不加也不报错只是校验静默失效等到脏数据进库才发现。我现在的习惯是凡是集合或嵌套对象字段无条件加Valid多写一个注解不会有副作用。至于校验失败后的错误信息我不建议把框架抛出的原始字段名直接返回给前端比如items[0].skuId这种。做法是在全局异常处理里把字段路径映射成中文字段名返回结构化的错误列表。这一步和对象位置无关但和 qo 的设计强相关——qo 的字段命名要克制、要面向用户别用skuIdListStr这种内部命名否则错误信息怎么写都别扭。4. 单模块分包与多模块拆包位置问题的两种解法4.1 单模块项目按层分包还是按功能分包小项目里web、service、dao三个顶层包按层划分对象放在各自层的子包里这是最常见的做法com.demo.web.vo、com.demo.service.bo、com.demo.dao.entity一目了然。项目长到几十个业务模块之后按层分包的坏处开始显现改一个订单功能要在三个顶层包里来回跳新人接手时找不到某个功能的完整边界。这时候我会切换到“按功能分包、层内再分层”的结构com.demo.order.web.vo、com.demo.order.service.bo、com.demo.order.dao.entity。功能优先层其次。好处是一个功能的全部代码在一个子树里删除功能就是删一个包。代价是跨功能复用的对象没地方放需要单独开一个common或shared包而common包往往是腐化的起点所以我给common定的规矩是只放真正被三个以上功能使用的对象且必须有明确 owner不允许任何人往里随手丢类。这两种结构没有绝对优劣我的判断依据是团队规模和迭代节奏。五个人以内、功能数少于十个按层分包足够超过这个规模按功能分包的可维护性优势会快速显现。关键是选定之后不要混用最怕的是同一个项目里一半按层、一半按功能找类全靠全局搜索。4.2 多模块工程对象的物理归属与依赖方向多模块是位置问题最较真的场景。我常用的拆分方式是三个模块xxx-api、xxx-service、xxx-web。xxx-api放对外契约——vo、qo、dto以及接口定义如果走 RPC 就是 RPC 接口如果走 HTTP 就是纯对象。xxx-service放 bo、DO、mapper、业务实现。xxx-web放 controller、拦截器、全局异常处理。依赖方向必须是单向的web → service → api或者更常见的web → service、web → api、service → apiapi不依赖任何内部模块。这条线一旦反向整个模块化就失去意义。我审查过一个项目api模块为了图方便引入了service模块的 bo结果api被打包发给外部调用方时把内部业务类一起带出去了包体积翻了三倍还暴露了内部字段。修复方式是把 bo 复制一份精简成 dto或者把 bo 里真正需要对外的字段抽出来。多模块下还有个常见争议qo 该放api还是web。如果这个 qo 只被本项目的前端调用放web更合适如果这个查询接口同时被其他服务以 RPC 方式调用那它就变成了跨进程契约应该提成 dto 放api。这两种情况在同一个项目里可以共存不要强行统一。判断标准是消费方是谁消费方是本项目的页面放 web消费方是别的进程放 api。表格对照一下两种结构的差异方便你对着自己的项目选维度单模块按层分包多模块拆分对象物理位置各层顶层包的子包api 模块放 vo/qo/dtoservice 模块放 bo/DO依赖约束靠约定和 code review靠 maven 依赖强制适合规模功能数少于十个中大型、多人协作主要风险包结构腐化、跨层引用模块循环依赖、契约泄漏迁移成本低中需要调整 import 和打包配置4.3 依赖方向的自动检查别只靠人盯约定写在文档里没人看我现在的做法是把它变成可执行的检查。最简单的一招是引入依赖分析工具在构建阶段扫描包依赖配置规则比如“web不允许被service依赖”、“dao不允许依赖web”违反就让构建失败。这类工具配置一次后面所有新人都自动被约束比口头强调一百遍管用。如果不想引入额外工具退而求其次的做法是写一个单元测试扫描service包下所有类的 import一旦发现web.vo或web.qo就 fail。几十行代码跑在 CI 里效果拔群。我还在团队里推过一个更土的办法在web包的package-info.java里写上“本包对象仅供接口层使用service 层禁止引用”虽然不强制但至少搜索的时候能看到这句话。最后提醒一个容易忽略的点日志和异常里也别把对象裸奔。有些人在 service 层打日志时直接把 qo 或 vo 的toString打出来如果这些对象里有手机号、身份证、金额日志系统就成了数据泄露的重灾区。我的处理方式是在 bo 和 DO 上重写toString时做脱敏在 vo 上干脆不重写需要打日志时手动拼关键字段。5. 踩坑记录与速查表位置放错之后会怎样5.1 我反复纠正的六个坏习惯第一个坏习惯是vo 继承 DO。表面省事实则把表结构绑死在接口契约上。数据库加一个字段接口响应自动多一个字段前端可能因为多出的字段触发校验失败或者暴露了不该暴露的信息。我现在的做法是看到class XxxVO extends XxxDO就要求重写没有例外。第二个坏习惯是qo 里塞业务控制字段。比如tenantId、userId、isAdmin这类字段如果出现在 qo 里意味着前端可以伪造。正确做法是从登录上下文里取在 service 层注入绝不由请求体传入。这条是安全红线我在任何项目里都不让步。第三个坏习惯是bo 直接返回给 controller。前面说过字段会泄漏。更隐蔽的问题是 bo 里可能含有惰性加载的关联对象序列化时触发额外查询甚至循环引用表现为接口偶发超时或栈溢出。这类问题排查起来极其痛苦因为本地数据量小的时候不会复现。第四个坏习惯是转换逻辑散落在各处。同一个 DO 转 VO 的逻辑A 接口写在 controller 里B 接口写在 service 里两边规则不一致前端拿到的数据格式对不上。解决办法是收敛到转换器一个方向的转换只允许有一个入口。第五个坏习惯是用 Map 代替对象。查询结果直接返回MapString, Object看起来灵活实际上是放弃了类型约束。字段名拼错、类型不匹配、IDE 无法跳转维护成本极高。我允许用 Map 的场景只有两个动态字段的扩展属性、以及真正无法预知结构的透传数据。其余一律建对象。第六个坏习惯是一个包塞下所有对象。model、pojo、bean这种大杂烩包名我基本见到就要求重构。它传达的信息量是零任何人打开都不知道某个类该不该被哪层引用。分包的本质是给依赖加约束包名越模糊约束越弱。5.2 常见问题速查表下面这张表是我这几年攒下来的遇到问题先查这里能省不少时间。现象大概率原因排查动作处理方式接口返回字段为 null转换器字段名不一致对比 bo 与 vo 的字段名拼写统一命名或显式映射前端多出未定义字段vo 继承了 DO 或直接返回 bo检查返回类型的继承链拆出独立 vo字段白名单参数校验不生效嵌套对象缺Valid检查集合字段上的注解字段级补Valid编译报找不到 getterLombok 与映射框架处理器顺序错查看构建配置的 processor 顺序调整顺序并加 binding 依赖金额显示精度异常vo 里用了浮点类型检查字段类型改用字符串或整型分日期差 8 小时序列化时区未统一检查全局序列化配置统一时区与格式修改表结构后接口报错DO 泄漏到接口层扫 controller 的 import引入 vo 隔离偶发栈溢出或超时bo 含关联对象被直接序列化检查返回对象的关联字段转换为扁平 vo表里最后两条值得多说两句。序列化引发的循环引用典型表现是接口在数据量大的时候超时、数据量小的时候正常因为小数据量下关联对象是空的序列化不会递归下去。定位方法是打开 SQL 日志看一次请求触发了多少条查询如果远超预期基本就是这个问题。解决思路不是加JsonIgnore打补丁而是从根上把 bo 转成扁平 vo关联数据在 service 层一次性查完、组装好。关于金额精度还有一个容易被忽略的场景vo 里用BigDecimal但没配置序列化策略。默认序列化可能输出科学计数法或者在某些框架版本下输出带尾随零的字符串前端处理起来很别扭。我的做法是统一在 vo 里用字符串承载金额格式化在转换器里完成前端拿到就能直接渲染。这个决定看起来牺牲了“类型正确性”但换来了跨端一致性值得。5.3 一个真实场景的完整推演拿订单详情接口举例把对象流转走一遍。请求进来OrderDetailQO承载订单号controller 上Valid校验非空和格式service 收到 qo先做权限校验从上下文取当前用户判断是否有权查看该订单再查订单主表得到OrderDO查明细表得到ListOrderItemDO查用户表得到下单人信息三者组装成OrderDetailBO组装过程中做金额计算、状态机校验、风控标记controller 拿到 bo通过转换器生成OrderDetailVO字段裁剪掉成本价、供应商 id、风控标记金额格式化成字符串状态翻译成文案最后序列化返回。这个链条里每个对象的边界都清晰qo 只负责接参和校验DO 只负责映射表bo 只负责业务组装vo 只负责展示。任何一环出问题改动范围都是可控的。如果哪天产品要求详情页加一个“预计送达时间”改动只有三处service 里计算并塞进 bo转换器里搬进 vovo 里加字段。DO 不用动qo 不用动数据库不用动。这就是位置放对之后带来的确定性。6. 一套可以直接抄的分包模板与渐进式改造步骤6.1 目录结构模板单模块项目我会用这套结构直接照着建包就行com.demo ├── web │ ├── controller │ ├── vo # 对外输出 │ ├── qo # 查询入参 │ ├── convert # bo - vo │ ├── validation # 校验分组 │ └── handler # 全局异常、拦截器 ├── service │ ├── impl │ ├── bo # 业务对象 │ ├── convert # DO - bo │ └── support # 领域服务、计算器 ├── dao │ ├── entity # DO / PO │ ├── mapper │ └── repository └── common ├── exception ├── result # 统一响应包装 └── util多模块项目在此基础上拆成三个模块包名保持一致只是物理位置变了xxx-api保留vo、qo、dto三个包和接口定义xxx-service保留service、dao两个大包xxx-web保留controller、convert、handler。注意api模块不要引入任何三方业务依赖只保留序列化、校验、文档这三类注解所需的依赖否则包会越来越重。关于common包我再强调一次它是腐化的高发区。我的做法是给common加一个 README写明“新增类必须说明被哪三个以上功能使用”并在 code review 时严格执行。没有这条约束半年后common里会堆积几十个没人敢删的工具类谁都不知道还有没有在用。6.2 老项目渐进式改造别一次动全量历史项目里 vo、qo、bo 混在model包是常态直接重构风险太大。我的改造顺序是先加固边界再拆对象最后清理旧包。第一步加固边界。在service层加一个单元测试扫描 import把跨层引用列成清单。不急着改先让问题可见。同时在 CI 里加一条规则新增代码不允许出现跨层引用存量代码加白名单。这一步几乎零风险但能阻止问题继续恶化。第二步从调用量最大的接口开始拆对象。选一个接口把它的返回类型从 DO 换成新建的 vo转换逻辑集中到一个转换器里。改完观察一周确认没有字段遗漏、没有前端报错再推下一个。我一般按接口 QPS 从高到低排高 QPS 的接口字段多、影响面大先改收益最高。第三步清理旧包。当model包里的类已经没有任何引用时删掉它。这一步最容易拖因为总有人担心“万一还有用”。我的处理方式是加Deprecated标注观察两个迭代周期没有新增引用就删。留着不用的大杂烩包只会诱导后来者继续往里扔东西。第四步把规则固化到模板里。新建功能时直接生成标准目录结构让正确的位置成为默认选项而不是靠记忆。团队里我推过一个项目脚手架建功能时自动生成vo、qo、bo、convert四个包和对应的空类新人根本没有机会放错位置。这个投入产出比非常高一次配置长期受益。6.3 几个不必纠结的细节有些争议点其实没有标准答案不必过度设计。比如 vo 的包名到底叫vo还是viewqo 叫qo还是query这些纯属命名偏好团队统一即可。再比如转换器到底叫Converter还是Assembler还是Mapper只要能一眼看出方向就行我习惯用Converter是因为它和Mapper持久层不会混淆。还有一个常被问的问题小项目要不要也分这么细。我的回答是要但可以合并。比如只有三五个接口的工具项目vo和qo可以放在同一个web/model包里但bo和 DO 依然要分开因为这两个混在一起的风险表结构冲击业务、内部字段泄漏和项目大小无关。分层的目的是控制变更影响范围只要这个目的达到包的数量可以商量。最后一个细节DTO 和 VO 什么时候合并。如果项目里前后端同源、没有跨进程调用那么 DTO 这个角色确实可以省掉vo 同时承担跨模块传输的职责。但一旦出现第二个消费方另一个服务、一个开放接口就必须把 DTO 拆出来因为两者的演进节奏完全不同vo 跟着页面改dto 要考虑版本兼容。我个人的经验是接入第一个外部调用方的时候就把 DTO 拆出来别等到第三个才动手那时候改造成本已经翻倍了。这套东西我在几个项目里反复落地过最深的体会是对象位置这件事前期多花的每一分钟都会在后面少花一小时。刚开始建包的时候觉得啰嗦等到线上出一次字段泄漏或者一次循环引用超时你就知道那些规则不是给别人看的是给自己省命的。