三公自动算账 DTO、VO、PO 到底要不要拆?一篇讲透 Java 实体分层的底层逻辑

---

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 评论

发表评论