DTO、VO、Entity、Domain 与防腐层:数据模型为什么不能混用
订单接口接收字符串金额,数据库保存最小货币单位,业务计算同时需要数值和币种,页面还可能显示本地化格式。同一个金额穿过这些位置,字段名可以相同,允许的输入和表达方式却各有要求。
模型设计先回答两个问题:这个对象供谁使用,以及哪些条件成立时它才有效。类名中的 DTO、VO、Entity 只是提示,具体约定要在代码和接口中明确。
常见模型名称分别指什么
从用途区分,而不只看后缀
| 名称 | 常见用途 | 需要明确的约定 |
|---|---|---|
| Request DTO | 接收 HTTP、RPC 或消息输入 | 字段可选性、格式、大小、调用者可设置哪些值 |
| Response DTO / View Object | 返回某个接口或页面需要的数据 | 可公开字段、精度、脱敏和兼容变化 |
| Persistence Entity / Row | 映射数据库结构 | 主键、列类型、关联、版本和持久化生命周期 |
| Domain Entity | 表达具有业务身份的对象 | 身份、状态变化和业务规则 |
| Value Object | 表达金额、地址、区间等值 | 值相等、不可变性以及有效组合 |
| Command / Query | 表达一次用例输入或查询条件 | 操作意图、授权上下文和执行前提 |
VO 常被同时用来缩写 View Object 和 Value Object。一个用于页面展示,一个用于表达业务值,在同一项目中应使用清晰命名,例如 OrderView 与 Money,减少讨论时的歧义。
“领域对象”描述业务角色,并不要求一定使用特定框架。JPA 的 Entity 则还受持久化规范约束,存在新建、受管理、游离和移除等状态,以及关系加载与同步行为。两种 Entity 可以由同一个类承担,也可以分别建模;采用同类方案时,要接受持久化映射对业务对象的影响。Jakarta Persistence 规范
身份相同与值相同
两个订单即使金额、买家和商品完全相同,只要业务 ID 不同,就仍是两笔订单。一个订单更新备注后,也仍是原来的订单。领域实体通常围绕稳定身份判断是否为同一对象。
金额的含义来自数值和币种。12.30 CNY 与 12.30 USD 不相同;12.3 CNY 和 12.30 CNY 是否相同,则需要先固定金额的规范表示。地址或区间还可能需要规范化大小写、单位或端点包含关系,不能仅对任意输入字段直接生成 equals。
Java record 自动提供组件访问器、equals 和 hashCode,适合表达许多值与传输对象。但它只保证组件引用不能重新赋值;组件指向可变 List 时,外部仍可能修改列表内容。需要在构造时复制集合,并避免返回可变内部对象。Java Record Classes
什么时候可以复用一个类
内部小型查询只有几个稳定字段,数据库行和读取结果完全一致,也没有敏感字段、懒加载或接口版本差异时,可以直接采用简单投影,减少重复转换。
以下变化通常值得分开模型:新增数据库内部备注却不希望公开;接口要保留旧字段而数据库已经重命名;外部服务用整数分而本地使用币种金额;写入请求不允许设置审核状态。分别建模后,这些变化可以在转换处处理。
不必为了每一层都拥有自己的对象,再制造五份逐字段拷贝。确定了变化原因和允许访问的字段,再选择必要的转换位置。
输入怎样成为有效的业务对象
格式校验、业务规则和授权
JSON 能解析,只表示输入符合相应语法。请求校验还要检查必填、长度、枚举和嵌套结构;领域构造检查金额精度、区间顺序等不变量;应用服务结合当前状态判断用户能否执行这项操作。
例如客户端可以提供购买数量,订单价格由服务根据商品和优惠规则计算。若把数据库 Entity 当作请求参数,并自动拷贝全部同名字段,调用者可能设置 price、ownerId 或 approved 等不该控制的内容。请求对象应只包含允许输入的字段,授权身份来自经过验证的上下文。
构造校验也覆盖非 HTTP 路径:批处理、事件消费或内部调用同样需要创建有效金额。仅在 Controller 上加注解,不能保护其他入口绕过校验后直接构造对象。
金额的表示与转换
实验的 Money 只支持 CNY、USD,固定两位小数且不允许负数。核心处理为:
amount = amount.setScale(2, RoundingMode.UNNECESSARY);
if (amount.signum() < 0) {
throw new IllegalArgumentException("NEGATIVE_AMOUNT");
}UNNECESSARY 要求调整精度时不能丢失非零小数:1.230 可以规范化为 1.23,1.234 会抛出异常。业务若允许舍入,应另行规定在哪一步、按哪种规则舍入,避免每个映射函数都自行选择 HALF_UP。BigDecimal
转换为最小货币单位时,实验使用 movePointRight(2).longValueExact(),要求结果能精确放入 long。两位小数只适用于本实验允许的币种及业务规则;支持更多货币后,应按币种和产品契约处理单位,不能统一乘 100。
BigDecimal 的 equals 比较数值及 scale,而 compareTo 比较数值顺序。先规范化到相同 scale,record 的值相等判断才能符合这里的 Money 约定。跨语言传输的表示进一步见数据类型与分页。
状态变化由操作表达
Order 的状态从 DRAFT 变为 CONFIRMED,实验通过 confirm() 返回新的不可变订单。第二次确认被明确拒绝。调用方无需先读取 state 再自行 setState,也不能绕过规则把状态设置为任意字符串。
这只是内存中的转换规则。两个请求同时加载同一订单时,仍需要数据库版本条件、锁或其他并发措施防止丢失更新。对象方法不能独自协调不同进程里的两份实例。从改价需求推导聚合,再将业务动作接入并发保存的完整过程,见 DDD 业务建模。
运行生成映射并检查 JSON 输出
建立实验环境
下载 工程质量实验,在 Linux amd64、Bash、Docker Engine 环境中解压,进入 quality-engineering。当前用户需要 Docker 权限和目录写权限。构建使用 Maven 3.9.12、Java 17、MapStruct 1.6.3,容器以宿主 UID/GID 执行,缓存保存在项目 .m2。
bash run-docker.sh -Dtest=ModelTest -Dsurefire.failIfNoSpecifiedTests=false
test -f adapter/target/generated-sources/annotations/example/quality/adapter/OrderMappingImpl.java || exit 1
test -f verification/target/surefire-reports/example.quality.verification.ModelTest.txt || exit 1预期 ModelTest 为 2 项通过。上游模块没有该测试类,因此允许跳过指定测试;最终 verification 报告必须存在。Java 25 对照可在命令前设置 BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25,源码编译目标仍为 17。
生成器处理结构,规则由业务代码执行
工程的转换接口使用真实 MapStruct 注解处理器:
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR,
imports = {Money.class, BigDecimal.class, Order.class})
public interface OrderMapping {
@Mapping(target = "total",
expression = "java(new Money(new BigDecimal(input.amount()), input.currency()))")
@Mapping(target = "state", expression = "java(Order.State.DRAFT)")
Order toDomain(CreateRequest input);
@Mapping(target = "amount",
expression = "java(order.total().amount().toPlainString())")
@Mapping(target = "currency", source = "total.currency")
OrderView toView(Order order);
}编译后查看生成文件,可以看到 MapStruct 创建 Order、调用 Money 构造并构造 OrderView。未映射目标字段策略设为 ERROR,能让相应遗漏在构建时出现;自定义表达式的业务正确性,仍要由测试验证。MapStruct Reference Guide
第一个测试输入 o-1、12.30、CNY,确认领域最小单位为 1230,再将确认后的订单转为公开视图。Jackson 实际序列化该视图,结果只包含 id、amount、currency 三个成员,不含内部状态。接口字段增删时,序列化结果检查会直接反映影响。
测试没有搭建 HTTP 服务;它验证的是生成转换和实际 JSON 结构。真正接入 Web 框架后,还需要覆盖框架配置、字段命名策略和响应处理。
逐种拒绝错误,再处理有效输入
第二个测试执行以下构造和状态操作:
| 输入或操作 | 预期结果 | 修正方式 |
|---|---|---|
| 金额 1.001,币种 CNY | 精度调整抛出 ArithmeticException | 提供符合精度的金额,或按明确业务规则先舍入 |
| 金额 1.00,币种 XYZ | UNSUPPORTED_CURRENCY | 使用支持的币种,不能静默改为默认币种 |
| 金额 -1,币种 CNY | 拒绝负金额 | 明确退款是否应采用另一种业务类型 |
| 对已确认订单再次 confirm | IllegalStateException | 查询当前状态,按重复请求策略处理 |
| 随后输入 1.00 CNY | 创建并确认,最小单位 100 | 验证错误没有污染后续对象 |
JUnit 检查每种异常类型或错误标识;错误输入被意外接受时,相应测试失败。最后一次有效输入用于检查后续构造与确认能否继续完成。
外部模型变化与常见转换故障
防腐层翻译的是业务含义
第三方付款返回的 status=SUCCESS 可能表示“受理成功”,本地订单的 PAID 却表示“付款已确认”。直接把字符串映射成同名枚举,会提前发货。适配代码需要结合第三方阶段、查询接口或回调约定,把它转换为本地明确状态。
防腐层通常包含供应商 DTO、协议客户端、单位和时间转换、错误映射,以及本地端口实现。应用服务使用本地类型;供应商新增字段或改动错误格式时,修改集中在适配层。原始响应保留到受控排查位置,注意支付数据与个人信息脱敏。
未知枚举应有显式处理:保存原始值并进入待确认、返回明确“不支持”,或在只读界面中显示未知。不能把所有未知状态都降为成功,也不能在异常分支悄悄返回空对象。
更新对象时保留“未提供”的含义
创建请求可以要求某字段必填,局部更新则可能区分未提供、显式 null 和新值。普通可空字段在反序列化后往往不能单独保留三种状态,需要选择字段存在性包装或明确的 Patch 协议。
MapStruct 的空值策略影响生成代码如何更新目标。忽略 null 可以保留原值,但这也可能使调用者无法清空可选字段。先定义接口含义,再选转换策略,不用一条全局“忽略 null”覆盖所有更新场景。
集合转换还需确定替换、合并还是按身份更新。清空订单行集合可能触发 ORM 孤儿删除;复制一个旧集合又可能覆盖并发新增项。持久化结果要在真实适配器测试中检查。
按转换位置定位问题
| 表现 | 常见原因 | 下一步 |
|---|---|---|
| 数据库新增字段后接口意外公开 | Entity 被直接序列化,或通用拷贝范围过大 | 建立明确输出类型,检查真实 JSON 字段 |
| 金额看起来相同却 equals 为 false | scale 未统一,币种不同 | 核对值对象规范化与相等规则 |
| MapStruct 找不到实现 | 注解处理未配置、编译未执行 | 检查 generated-sources 和编译器日志 |
| 字段已映射但业务状态错误 | 同名字段含义不同,默认值掩盖未知状态 | 对照外部协议,编写状态组合测试 |
| 序列化触发额外 SQL 或会话异常 | 输出仍持有 ORM 关联或代理 | 在受控查询范围内转换为所需投影 |
| DTO 类过多且总是同步修改 | 没有实际变化差异,转换层空转 | 合并内部稳定模型,保留真正对外的约束 |
实验命令结束后容器自动移除,编译产物和缓存留在解压目录。生成文件用于检查转换结果,修改应回到映射接口和模型源码,下次 clean 构建会重新生成实现。
权威资料与规范地址
值对象与数值表示
- Java Record Classes:https://docs.oracle.com/en/java/javase/17/language/records.html
- BigDecimal:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/math/BigDecimal.html
