DDD 系列:DTO、VO、DO、Entity 怎么区分

上一篇文章中,我们把 DDD 项目的分层和目录结构整理了一遍。目录有了之后,新的问题马上就来了:一个订单请求从 Controller 进入系统,经过应用层、领域层和数据库,途中到底要用多少种对象?

DTOVODOEntity 这些名字在不同团队里的含义并不完全一致。最麻烦的不是命名不同,而是同一个类从接口一路传到数据库,最后谁都不敢改。

这一篇不争论哪个缩写最正宗,只按这套 DDD 项目里的职责把边界说清楚。

区分这些对象的关键,不是类名后缀,而是它服务于哪一层、表达什么含义、允许谁依赖。

一、先看完整的数据流

一个创建订单请求,大致会经历下面的转换:

text 复制代码
HTTP JSON
  -> CreateOrderRequest
  -> CreateOrderCommand
  -> Order 聚合
  -> OrderDO / OrderItemDO
  -> 数据库

查询返回则可能是:

text 复制代码
数据库查询结果
  -> OrderListDTO
  -> OrderListVO
  -> HTTP JSON

每次转换看起来有点麻烦,但它换来的是边界稳定:接口字段变化不直接冲击领域模型,表字段变化也不会一路传到前端。

二、Request 和 Response:接口协议对象

Request、Response 或接口层 VO 直接服务于 HTTP、RPC 等外部协议。

java 复制代码
/**
 * 创建订单请求
 *
 * 字段命名、校验注解和 JSON 格式均属于 HTTP 接口契约。
 */
public record CreateOrderRequest(
    @NotBlank(message = "买家编号不能为空") String buyerId,
    @NotEmpty(message = "商品明细不能为空") List<OrderItemRequest> items,
    @NotNull(message = "收货地址不能为空") AddressRequest address
) {
}

它可以带 Jackson、Validation、Swagger 等接口技术注解,但不应该进入领域层。

Response 也是一样,它围绕调用方展示需求设计,可以包含格式化时间、展示文案和权限控制后的字段。

接口对象是对外契约,不是领域模型。 前端需要一个 statusText,不代表订单聚合里也要保存一个状态中文名。

三、Command 和 Query:应用用例参数

Command 表示想让系统执行一个会产生业务变化的意图,Query 表示一次读取请求。

java 复制代码
/**
 * 创建订单命令
 *
 * 该对象只携带完成创建订单用例所需的数据,不包含 HTTP 和 JSON 相关注解。
 */
public record CreateOrderCommand(
    String operatorId,
    String buyerId,
    List<CreateOrderItemCommand> items,
    AddressCommand address
) {
}

Controller 把 Request 转成 Command 后,应用服务就不再依赖 Web 协议。以后同一个用例由 MQ 或定时任务触发,也可以直接构造 Command 调用。

简单项目中,Request 和 Command 字段可能完全一致,团队也可以合并,但要接受应用层会依赖接口协议这个代价。只要业务开始复杂、同一用例有多个入口,分开通常更稳妥。

四、Entity:有身份和生命周期的领域对象

在本系列中,Entity 指领域实体,而不是"所有带数据库主键的 Java 类"。

java 复制代码
/**
 * 订单实体,同时也是订单聚合根
 *
 * 订单通过订单编号识别业务身份,状态只能通过业务行为变更。
 */
public class Order {

    /** 订单编号,用于稳定识别同一笔订单 */
    private final OrderId orderId;

    /** 订单当前状态 */
    private OrderStatus status;

    /** 支付订单,内部维护状态约束 */
    public void pay() {
        if (status != OrderStatus.PENDING_PAYMENT) {
            throw new IllegalStateException("当前订单状态不允许支付");
        }
        status = OrderStatus.PAID;
    }
}

领域 Entity 表达业务身份、状态和行为,不应该出现 @TableName@JsonFormat 之类与持久化或接口协议绑定的注解。

有些项目把数据库对象也叫 Entity,这是团队命名习惯,不是绝对错误。但同一个项目里必须统一,否则看到 OrderEntity 根本不知道它是领域实体还是数据库记录。

五、DO:数据库持久化对象

DO,也有人叫 PO、POJO 或 Persistence Object,主要用于映射数据库结构。

java 复制代码
/**
 * 订单数据对象
 *
 * 该对象只映射 orders 表,不承载订单支付、取消等领域行为。
 */
@TableName("orders")
public class OrderDO {

    /** 数据库主键 */
    private Long id;

    /** 订单业务编号 */
    private String orderNo;

    /** 订单状态数据库编码 */
    private String status;

    /** 乐观锁版本号 */
    private Integer version;
}

DO 可以带 ORM 注解,可以与表字段相对接近,但不要把核心业务规则写在里面。数据库默认值、逻辑删除标记、审计时间等技术字段也可以留在 DO 中,不必污染领域模型。

DO 解决怎么存,领域 Entity 解决业务上它是谁、能做什么。

六、DTO 和 VO 到底是什么

DTO 的全称是 Data Transfer Object,本质是跨层或跨进程传输数据的对象;VO 在不同语境中可能是 View Object,也可能是 Value Object。

为了避免混乱,这套项目建议:

  • 页面展示对象明确命名为 OrderDetailVO
  • 领域值对象直接使用业务名,例如 MoneyAddress,不要叫 MoneyVO
  • 跨服务契约使用 OrderDTO 或更明确的 OrderSummaryDTO
  • 数据库对象统一使用 OrderDO

同一个 VO 同时表示 View Object 和 Value Object,读代码时非常痛苦。领域值对象优先使用业务名称,是最省解释成本的做法。

七、转换代码放在哪里

不同边界使用不同转换器,不要写一个全局万能 BeanConverter

1. 接口对象转应用命令

这一步属于接口适配,可以放在 Controller 附近的 Assembler:

java 复制代码
/** 将 HTTP 请求转换成创建订单命令 */
public CreateOrderCommand toCommand(String operatorId, CreateOrderRequest request) {
    return new CreateOrderCommand(
        operatorId,
        request.buyerId(),
        request.items().stream().map(this::toItemCommand).toList(),
        toAddressCommand(request.address())
    );
}

2. Command 转领域对象

创建复杂聚合时,可以由应用层 Assembler 或领域工厂完成。包含库存校验、价格计算等领域规则时,优先交给领域工厂或聚合行为,不要塞进字段转换器。

3. 领域对象和 DO 转换

这一步属于持久化适配,放在基础设施层的 Converter 中。它需要了解表结构和领域对象两边,但领域层不应该反向依赖它。

4. 查询结果转 VO

列表和报表查询可以直接查出专用 DTO,再由查询服务或接口层组装 VO。没有业务行为的只读查询,不必先恢复完整聚合。

八、为什么不要一套对象走到底

一套 OrderDTO 从 Controller 传到 Mapper,看起来代码最少,但会产生连锁影响:

  • 前端新增展示字段,数据库对象被迫跟着加;
  • 表字段重命名,接口契约可能一起变化;
  • ORM 和 JSON 注解堆在同一个类上;
  • 接口容易把内部字段、成本价或逻辑删除标记直接返回;
  • 领域行为只能继续散落在 Service,因为 DTO 只是数据袋子。

对象转换确实有成本,所以也不需要机械地每一层都复制一份。判断标准是:边界两侧是否存在不同的变化原因。

如果只是内部很小的查询方法,DTO 和 VO 完全相同,可以直接返回;如果是对外接口、核心聚合或长期维护模块,分开通常更安全。

九、几个常见误区

1. 使用 BeanUtils 复制所有字段

同名字段不一定同义,金额还可能涉及单位,状态还可能涉及编码转换。核心模型建议显式映射,编译器也能在字段变化时提醒我们。

2. Entity 必须和表一一对应

领域实体围绕业务身份建模,一个聚合可以对应多张表,一张宽表也可以拆成多个领域概念。是否建表不是判断实体的标准。

3. 所有查询都返回领域实体

报表和列表只是读取数据,强行恢复聚合会增加无用查询和转换。领域实体应该用于执行业务行为,不是统一返回格式。

4. 为了"纯净"创建几十个完全相同的类

边界隔离要有实际收益。字段、校验、变化原因完全相同,且只在模块内部短距离传递时,可以适当合并。DDD 不是对象后缀收集游戏。

十、怎么验证对象边界

可以做下面几项检查:

  1. 修改 Response 展示字段,领域实体和数据库表不应该被迫修改;
  2. 修改 DO 的审计字段,外部接口不应该跟着暴露;
  3. 领域实体单元测试不需要启动 Web 容器和数据库;
  4. 转换器测试覆盖状态编码、金额单位、空值和集合映射;
  5. 静态搜索确认领域层没有 Jackson、MyBatis 和 JPA 技术注解。

看到外部协议、领域模型和数据库结构能够在各自边界内变化,说明这些对象的区分确实起作用了。

十一、总结

这一篇主要整理了 DTO、VO、DO 和 Entity 的职责。

Request/Response 服务接口协议,Command/Query 服务应用用例,Entity 表达领域身份和行为,DO 映射数据库,DTO/VO 则按明确的传输或展示边界使用。

对象不必越多越好,但不要让一个类同时承担接口、业务和存储三种变化原因。

下一篇我们就基于这些分层和对象,从一个普通的增删改查接口开始,把请求、应用服务、聚合和仓储真正串起来。

相关推荐
自由随风飘2 小时前
JAVA中的面向对象-3
java·开发语言
桐薇全肯定2 小时前
MySQL学生成绩管理系统实战操作
java·数据库·sql
Nuanyt3 小时前
SSM 学习记录 第二部分 Spring整合Mybatis&Junit AOP核心概念 Spring事务管理 SpringMVC 请求与响应 Rest风格
java·spring·junit·mybatis·restful
醉城夜风~3 小时前
C++模板详解:从基础到高级全面解析
java·开发语言·c++
程序员天天困4 小时前
Arthas Profiler 火焰图实战:CPU 热点在哪一目了然
java·jvm·后端
我命由我123454 小时前
Android 在构建过程中,发现有两个依赖库都包含了相同路径的资源文件
android·java·开发语言·java-ee·kotlin·android-studio·android runtime
用户3126874877204 小时前
@Autowired 注入的是代理对象?Spring AOP 代理选择原理一次讲透
java·spring
@insist1234 小时前
信息系统管理工程师-任务、标准、EDM 过程与关键域
java·大数据·数据库