关键词:VO、DO、DTO、Java 分层架构、领域模型、数据传输对象
刚入行的时候,最怕看到项目里一堆XxxVO、XxxDO、XxxDTO飞来飞去。明明都是"装数据的类",为什么还要分这么多种?能不能干脆统一用一个Xxx搞定?
答案先放这:短期能跑,长期必乱。这篇文章就把这三个"O"讲清楚,顺便给你一套能直接抄的项目分层范本。
一、先给结论:它们各管一段
| 对象 | 全称 | 中文 | 活动区域 | 关注点 |
|---|---|---|---|---|
| DO | Data Object / Domain Object | 数据对象 / 领域对象 | 数据库 ↔ DAO 层 | 表结构、持久化 |
| DTO | Data Transfer Object | 数据传输对象 | 服务层 ↔ 控制层(跨进程/跨层) | 精简、安全、按需组装 |
| VO | View Object / Value Object | 视图对象 | 控制层 ↔ 前端 | 页面展示、格式化 |
一句话记忆:DO 贴近数据库,DTO 贴近传输,VO 贴近屏幕。
二、DO:和数据库表"同呼吸共命运"
DO 是数据持久层的核心,字段基本和数据库表一一对应。它的唯一职责,就是忠实地映射表结构。
// 用户表 user 对应的 DO@TableName("user")publicclassUserDO{privateLongid;privateStringusername;privateStringpassword;// 注意:敏感字段privateStringphone;privateIntegerstatus;privateDatecreateTime;privateDateupdateTime;// getter / setter 省略}要点:
- 字段名、类型尽量和表保持一致,别在这层做"展示优化"。
- 密码、盐值这类敏感字段可以存在于 DO,但绝不能一路传到前端。
- 在 MyBatis / JPA 的 Entity 里,DO 和 Entity 经常是同一类东西的不同叫法。
三、DTO:跨层传输的"瘦身快递盒"
当 Controller 要调 Service,或者微服务之间要传数据,你不会把整个 DO 丢过去(里面还带着密码呢)。DTO 就是为了按需组装、减少冗余、屏蔽敏感字段而存在的。
// 创建用户的请求 DTOpublicclassUserCreateDTO{@NotBlank(message="用户名不能为空")privateStringusername;@Pattern(regexp="^1[3-9]\\d{9}$",message="手机号格式不正确")privateStringphone;// 没有 password、没有 id、没有时间戳}// 服务间返回的 DTO(聚合了用户和订单概要)publicclassUserProfileDTO{privateLonguserId;privateStringnickname;privateIntegerorderCount;privateBigDecimaltotalSpent;}要点:
- DTO 里通常带参数校验注解(
@NotBlank、@Pattern),它是"入参契约"。 - 一个 DO 可以拆成多个 DTO(列表页 DTO、详情页 DTO),反向也可以多表聚合成一个 DTO。
- 跨服务调用时,DTO 就是接口的"对外承诺",改字段要慎重(涉及兼容性)。
四、VO:只给前端"看得到的东西"
VO 是最后一道关,专门给前端页面用。它做的是格式化 + 裁剪:日期转成字符串、枚举转成中文、数字加上单位、把嵌套结构摊平。
// 前端用户列表展示用的 VOpublicclassUserListVO{privateLongid;privateStringusername;privateStringphoneMasked;// 138****8888privateStringstatusText;// 1 -> "正常",0 -> "禁用"privateStringcreateTimeText;// 2026-07-17 10:30}// 转换过程(典型在 Controller 或 Converter 里)UserListVOvo=newUserListVO();vo.setId(do.getId());vo.setUsername(do.getUsername());vo.setPhoneMasked(maskPhone(do.getPhone()));vo.setStatusText(do.getStatus()==1?"正常":"禁用");vo.setCreateTimeText(format(do.getCreateTime()));要点:
- VO 里不要出现业务计算逻辑,只做"展示所需的数据搬运"。
- 前端要什么格式,VO 就长什么样——它是以页面为中心的。
- 敏感字段在 VO 层面必须彻底消失。
五、三者怎么串起来?一张流程图看明白
[ 数据库 ] │ (MyBatis 查询) ▼ UserDO ──► Service 层做业务处理 │ │ (DO → DTO,跨层/跨服务传) ▼ UserDTO ──► Controller 接收 / 转发 │ │ (DTO/DO → VO,按页面需求组装) ▼ UserVO ──► 返回给前端渲染简化记忆:DO 进来,DTO 跑腿,VO 出去。
六、新手最容易踩的 3 个坑
- 一个类打天下:前端要改个字段展示,你直接改了 DO,结果把数据库映射也搞崩了。分层就是为了"改展示不动存储"。
- DTO 当 VO 用:把带密码的 DTO 直接序列化返回前端——这是真实发生过的严重事故。
- 过度设计:只有 3 张表的小项目,硬凑 VO/DO/DTO 三套,反而拖慢开发。中小项目可以
DO + VO两件套起步,等业务复杂了再补 DTO。
七、什么时候该用,什么时候可以省?
| 项目规模 | 推荐组合 | 说明 |
|---|---|---|
| 内部小工具 / Demo | Entity 直接用 | 怎么快怎么来 |
| 普通单体后台 | DO + VO | Controller 直接转 VO 返回 |
| 标准分层后台 | DO + DTO + VO | Service 出 DTO,Controller 转 VO |
| 微服务 | DTO(强约束)+ VO | 跨服务必须用 DTO 当契约 |
八、总结
- DO忠于数据库,DTO忠于传输,VO忠于页面。
- 分层的本质不是"多写几个类",而是隔离变化:存储结构、接口契约、展示需求,三者各自演进互不干扰。
- 别为了分层而分层,但一旦项目超过"玩具级",这套三件套就是你的安全带。
如果你现在项目里还是"一个 User 类走天下",不妨从今天起,先把返回前端的对象单独抽成 VO,这一步成本最低、收益最明显。
觉得有用点个赞 👍,关于 DTO 和 VO 的互相转换(MapStruct / 手写 Converter),下篇可以展开讲。评论区说说你们项目用的是几件套?