---
DTO、VO、PO 到底要不要拆?一篇讲透 Java 实体分层的底层逻辑
2026 年 9 月 22 日
---
一、先回答核心问题:要拆
结论前置:要拆。但拆多少,取决于项目规模。
不拆的后果随着项目增长会逐渐暴露:[citation:70.7][citation:70.9]
数据库结构绑死前端
一个 Entity 被 N 个接口复用
改表如拆炸弹
安全字段泄露
项目后期无人敢动
分层实体本质是在建立变化隔离带——数据库变不影响接口,接口变不影响数据库。[citation:70.9]
---
二、三层核心实体定义
数据库
↓
PO (Persistent Object) ← DAO 层,映射数据库表,绝不出 Service
↓
DTO (Data Transfer Object) ← Service 边界,跨层传输数据
↓
VO (View Object) ← Controller 层,面向前端展示
↓
前端
| 对象 | 所在层 | 核心职责 | 是否能到前端 | 后缀示例 |
|:----:|:------|:---------|:-----------:|:--------|
| PO / Entity | DAO / Repository | 映射数据库表结构 | ❌ | UserPO、OrderEntity |
| DTO | Service 边界 | 跨层传输数据、聚合查询结果 | ❌ | UserDTO、OrderCreateDTO |
| VO | Controller 层 | 接口出参,面向前端展示 | ✅ | UserVO、OrderDetailVO |
PO 里常见的情况:存了 password、create_time、deleted 等字段。如果直接返回给前端,密码泄露+字段冗余双杀。[citation:70.13]
---
三、实际案例:为什么要拆
不拆的后果
// ❌ 直接返回 PO —— User 里包含 password、create_time、deleted
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userService.getById(id); // 这里直接把 PO 返回给前端了
}
前端需要的是 id、name、脱敏后的 phone(1381234)、roleName。而 PO 里是 id、name、phone、password、roleid、createtime、deleted。[citation:70.13]
拆开之后
// PO - 只对应数据库表
public class UserPO {
private Long id;
private String name;
private String phone;
private String password;
private Long roleId;
private LocalDateTime createTime;
private Integer deleted;
}
// DTO - 跨层传输
public class UserDTO {
private Long id;
private String name;
private String phone;
private String roleName; // 关联查询填充
}
// VO - 前端展示
public class UserVO {
private Long id;
private String name;
private String phone; // 此处已脱敏:1381234
private String roleName;
}
---
四、分层后的核心矛盾:转换
拆了就要转。转的工具有三个档次:
| 工具 | 方式 | 性能 | 类型安全 | 推荐度 |
|:----|:----|:----:|:-------:|:-----:|
| MapStruct | 编译期生成代码 | ⭐⭐⭐⭐⭐ | ✅ | 首选 |
| BeanUtils | 运行时反射 | ⭐⭐⭐ | ❌ | 简单场景可用 |
| 手动 set/get | 手写 | ⭐⭐⭐⭐⭐ | ✅ | 字段少时 |
MapStruct 示例:[citation:70.4][citation:70.7]
@Mapper(componentModel = "spring")
public interface UserConverter {
UserDTO toDTO(UserPO po);
UserVO toVO(UserDTO dto);
@Mapping(target = "phone", expression = "java(desensitize(dto.getPhone()))")
UserVO toVOWithDesensitize(UserDTO dto);
default String desensitize(String phone) {
return phone.replaceAll("(\d{3})\d{4}(\d{4})", "$1$2");
}
}
注意:不要在 Converter 里写业务逻辑。状态翻译、金额计算放在 Service 层,转换器只做字段映射。[citation:70.12]
---
五、进阶:用 ArchUnit 防止 PO 外泄
策略定了,但团队里总有人图省事直接返回 PO。用 ArchUnit 在测试阶段就把这种违规拦下来:[citation:70.1][citation:70.7]
@Test
void ensurePO_notExposed() {
JavaClasses classes = new ClassFileImporter().importPackages("com.example");
ArchRule rule = classes()
.that().areAssignableTo(UserPO.class)
.should().onlyBeAccessed()
.byClassesThat().resideInAnyPackage("..dao..", "..service..");
rule.check(classes);
}
可以在 CI 门禁中自动执行,分层违规直接构建失败。
---
六、按项目规模选择策略
| 项目规模 | 建议 | 理由 |
|:--------|:-----|:------|
| 小项目 / 原型 | PO = DTO = VO | 快速验证,迭代第一 |
| 中型项目 | 分 PO / DTO,VO 按需 | 平衡开发效率与解耦 |
| 大型 / 长期维护 | PO ≠ DTO ≠ VO + ArchUnit | 安全第一,变化隔离 |
核心原则:PO 永远不出 Service 层。至于 DTO 和 VO 要不要拆,看前端消费方有多少——一个前端用一个 VO;多个客户端(Web/App/开放API)各需各的 VO,DTO 做统一中转。[citation:70.8][citation:70.13]
---
七、一句话总结
PO 对应数据库,DTO 对应传输契约,VO 对应前端展示。 拆分的核心目的不是增加代码量,而是建立"变化隔离带"——数据库结构变了不用改接口,接口需求变了不用动数据库。用 MapStruct 做编译期转换解决性能顾虑,用 ArchUnit 做架构校验防止 PO 外泄。小项目可以合并,大项目必须拆,中间规模的按团队约定走。底线只有一条:PO 绝不出 Service 层。[citation:70.1][citation:70.7][citation:70.13]
0 评论