解决DTO泛滥的问题

70 阅读4分钟

DTO(或VO)的过度使用,确实会在中大型项目中带来“类爆炸”问题:一个用户实体,可能对应 UserCreateDTOUserUpdateDTOUserLoginDTOUserResponseVO……不仅数量多,而且彼此相似,维护成本很高。

要解决 DTO泛滥,不能只靠包组织,需要从 设计理念代码结构 两方面一起优化。


一、先治本:减少不必要的DTO

1. 不是每个Entity都需要单独的DTO

很多项目不敢直接将 Entity 暴露给 Controller,但如果你能保证以下几点,完全可以复用:

  • 实体中没有敏感字段(如密码、加密盐)。
  • 实体字段命名与前端预期一致(或通过 @JsonProperty 调整)。
  • 不需要对字段进行额外格式化(如日期转换)。
  • 序列化行为可控(如忽略 @Transient 或懒加载属性)。

👉 实践:对内部管理系统、简单的查询接口,直接返回 EntityList<Entity>,能省则省。只在需要裁剪、聚合、转换时才创建DTO。

2. 多个场景共用同一个DTO

  • 输入输出共用:一个 UserDto 既作为 @RequestBody,又作为返回值。通过 @JsonView(Spring)或校验分组(@Validated)区分必填字段。
  • 创建和更新共用:唯一区别是 id 字段;更新时id由路径传入,DTO中可以没有id,也可以复用但标记为可选。
  • 详情和列表共用:如果列表只比详情少几个字段,可以用同一个DTO,只是部分字段为 null;或者用继承:UserBasicDTOUserDetailDTO extends UserBasicDTO

3. 使用 record(Java 14+)替代传统DTO

public record UserResponse(Long id, String username, String email) {}

一行代码定义不可变DTO,极大减少样板代码,从根源上降低“写DTO的抵触感”。

4. 拥抱 GraphQL 或类似技术

不再需要为每个前端视图设计专用的DTO。前端直接声明所需字段,服务端返回准确的JSON结构。虽然技术栈变化较大,但能彻底消灭“响应VO泛滥”。


二、再治标:合理的包组织,让泛滥更可控

即使保留多个DTO,清晰的组织也能大幅降低认知负担。

❌ 反模式:全局大杂烩

com.example.dto
├── UserCreateDTO.java
├── UserUpdateDTO.java
├── UserLoginDTO.java
├── UserResponseVO.java
├── OrderCreateDTO.java
...

所有DTO堆在一起,很快就无法维护。

✅ 推荐模式:按业务模块 + 按角色分层

方式一:按模块聚合,内部再分 request / response

com.example.user
├── controller
├── service
├── repository
└── dto
    ├── request
    │   ├── UserCreateRequest.java
    │   ├── UserUpdateRequest.java
    │   └── UserQueryRequest.java
    └── response
        ├── UserDetailResponse.java
        └── UserListResponse.java

优点:同一个业务的所有DTO内聚在一起,request/response 一目了然。

方式二:直接按使用场景命名,放在 model 包下

com.example.user.model
├── UserRequest.java     // 包含所有可能的请求字段(通过校验组区分)
├── UserResponse.java    // 全量返回
└── UserBriefResponse.java // 精简版

对于小型项目足够,无需再分 request/response 子包。

方式三:将DTO与API定义放在一起(契约优先)

com.example.api.user
├── UserApi.java         // Feign 或 Controller 接口
├── CreateUserCommand.java
├── UpdateUserCommand.java
├── UserView.java
└── UserSummaryView.java

特别适合微服务之间或前后端严格基于Swagger/OpenAPI的开发模式。

核心原则

  • 内聚性:和谁一起变,就和谁放在一起。用户相关的DTO永远放在 user 包内,不要跨模块共用(除非是全局公共DTO)。
  • 可见性:包名后缀直接表达意图 —— request / response / command / view / dto 均可,但全项目统一。
  • 限制层级:最多两层(模块 + 角色),不要出现 dto.request.create.v1 这种过度嵌套。

三、配套工具与规范

  1. MapStruct 或 BeanUtils:即使DTO多,转换代码也要极简。
  2. ArchUnit 写规则:禁止从 Controller 直接返回 Entity 类型(除非在指定白名单内)。
  3. 定期重构:如果发现两个DTO字段完全一致,果断合并;如果发现DTO与Entity字段完全一致且无额外逻辑,考虑删除DTO直接暴露Entity。

四、总结:最佳实践清单

问题解决策略包组织示例
输入、输出DTO几乎一样合并为一个DTO,用 @JsonView 区分user.dto.UserDto
创建和更新只有id不同统一用 UserUpsertRequest,id放在路径中user.dto.request.UserUpsertRequest
列表和详情返回不同字段继承:UserBaseView + UserDetailView extends UserBaseViewuser.dto.response
全局公用的分页参数、结果抽象为 PageRequestPageResponsecommon.dto.PageRequest, common.dto.PageResponse
多个模块共用同一个实体(如 Address放在公共模块 common.model.Address,不作为DTO直接复用POJO

核心思想:不要为了分层而分层。当你能用 Entity、能复用、能用 record、能用继承时,就不要新建一个独立的DTO类。当确实需要多个DTO时,用业务模块 + 请求/响应子包的方式组织,避免全局混乱。