Java 类型与对象模型:从领域不变量到运行时边界
先看一个很常见的改法。结账接口收到折扣 JSON,Controller 直接把 DTO 交给计价服务;后来为了做去重,又把这个可变 DTO 放进 HashSet。接口一直能跑,直到某次字段更新后,集合里明明有这条记录,contains 却返回 false;另一批请求则把空的折扣规则带到计算深处,最后在 rule.apply() 上抛出 NullPointerException。
这两个故障看起来一个属于集合,一个属于空指针,根子却相同:外部数据没有在进入领域层时变成一个约束稳定的对象。类型不只是让 IDE 自动补全的标签。它决定非法值在哪里被拒绝、调用者能看见什么操作、对象放进集合后哪些事实不能再变,也决定新增折扣实现时哪些分支必须跟着修改。
“结账与折扣规则”同时经过 DTO、值对象和运行时策略,是观察类型边界的合适现场:DTO 停在边界,值对象守住范围,接口承接运行时策略;可变哈希键、空依赖和数组错误则会稳定暴露边界失守后的结果。
Java 8、11、17、21、25 的长期支持含义、功能差异与选择门禁见Java 版本与运行基线。下面的示例都能用 Java 17 编译;需要确认类型转换、类、接口、数组等精确语义时,再查文末对应的 Java SE 25 规范章节。编译基线和查阅哪一版规范是两个不同问题。
对象进入领域后,约束必须持续成立
领域不变量、值对象与实体、声明类型与运行时对象、接口和组合、record、sealed、不可变性、null 以及 equals/hashCode 共同决定对象能否安全穿过边界。重写、重载与数组反向实验分别暴露编译期选择和运行时检查的差别。
泛型类型推导、注解与反射、异常体系、集合与 Stream、JVM 类加载和对象内存布局各有后续专篇;这里仅在边界相接处解释现象,避免把实现细节混进语言模型。
运行反向实验前确认语言与运行时层次
能阅读类、接口、构造器和方法调用的基本 Java 代码。本机安装 JDK 17 或更高版本;规范基线是 Java SE 25,示例以 javac --release 17 编译。已理解源码、class、JVM 进程属于不同层次;下面只追语言可观察语义,类如何装载交给 JVM 专题。
先确认编译器与运行器来自预期 JDK:
java -version
javac -version折扣 DTO 进入领域层以后,问题才真正开始
对象建模的第一问不是“用类还是接口”,而是它承担哪一种身份语义。值对象由内容定义,例如金额、时间区间和地址快照;两个组件相同的值可以互换,通常应不可变并按值比较。实体由持续身份定义,名称、状态或归属变化后仍是同一个业务对象;它的相等性不能随可变属性漂移。HTTP DTO、数据库记录和消息载荷则是边界模型,它们承担字段兼容、框架构造和历史数据责任,不应未经校验直接成为领域对象。
record 适合透明的值载体,因为编译器根据组件生成访问器、equals、hashCode 和 toString;但它不会自动建立业务不变量,也不会让可变组件深度不可变。紧凑构造器仍要拒绝非法范围,数组和集合组件仍要考虑防御性复制。实体则通常用普通类封装状态迁移,让 pay()、cancel() 这类命名操作同时验证前置状态,而不是公开一组可任意组合的 setter。
record RateDiscount(int basisPoints) implements PricingRule {
RateDiscount {
if (basisPoints < 0 || basisPoints > 10_000) {
throw new IllegalArgumentException("basisPoints must be in [0, 10000]");
}
}
}边界适配器应先解析、处理版本默认值并校验,再构造领域对象;输出时反向映射为稳定契约。是否分离 DTO 与领域类型,不看“多写了几个类”,而看两者变化原因是否相同:外部字段要兼容旧消费者,领域规则要保护当前业务不变量,两者生命周期不同就应隔离。这样做也能阻止 Jackson 注解、ORM 代理和消息 schema 侵入核心模型。
一次报价从 JSON 走到 rule.apply
Java 是强静态类型语言:每个变量和表达式在编译期都有类型。类型先限制可存的值和可用的操作,编译器据此检查赋值、参数、返回值和方法选择;运行时对象再提供真正被重写的方法实现。完整链路如下:
如果外部 JSON 中的折扣是 -10,把它读成 int 只完成了表示转换,并没有建立业务有效性。只有当 Discount 的构造入口拒绝负数,系统才真正拥有“有效折扣”这个类型边界。类型安全不能替代输入校验;它负责让校验后的事实在进程内持续成立。
复制变量,不等于复制对象
八种基本类型的变量直接保存相应种类的值;引用类型包括类、接口、类型变量和数组,其变量保存指向对象的引用或 null。这一区别直接影响复制和相等判断:
long firstPrice = 10_000;
long secondPrice = firstPrice; // 复制金额数值
MutableKey firstKey = new MutableKey("order-7");
MutableKey secondKey = firstKey; // 复制引用,仍指向同一对象firstPrice 后续重新赋值不会影响 secondPrice。但如果经由 firstKey 修改订单键,secondKey 也会观察到变化,因为两个变量指向同一个对象。这正是后面哈希集合失联的前提。这里无需猜对象在栈还是堆;语言层真正稳定的结论是“值被复制”还是“引用被复制”。对象的具体内存布局属于 JVM 专题。
装箱把基本值转换为包装对象,拆箱反向转换。Integer count = null; int n = count; 能通过编译,却会在拆箱时抛出 NullPointerException。因此 DTO 中用包装类型表达“字段缺失”时,进入领域模型前必须处理缺失语义,不能让自动拆箱替团队做决定。
接口变量为什么仍会执行固定折扣
看一行代码:
PricingRule rule = new FixedDiscount(1_000);PricingRule 是表达式被赋值后的声明类型,决定调用点能看见哪些成员;FixedDiscount 是对象的运行时类,决定被重写方法最终执行哪个实现。接口变量不能直接调用 FixedDiscount 独有方法,即使当前对象恰好属于该类——这正是抽象边界提供的隔离。
向上转型通常安全,因为实现承诺了接口契约。向下转型则在运行时检查,错误假设会成为 ClassCastException。频繁出现“先 instanceof 再强转”往往说明调用者知道了过多实现细节;优先考虑把变化行为放回接口,或用一个明确的封闭类型分支处理器集中穷举。
数组还有一个容易误判的历史边界:引用数组是协变的,所以 Object[] values = new String[1] 可以编译;但写入整数会根据数组的运行时类型抛出 ArrayStoreException。这说明“能赋值”不等于“后续每次写入都安全”。泛型集合如何把这类错误提前,将在泛型专篇展开。
不要用重载模拟折扣策略
重写发生在子类型提供与父类型契约相匹配的实例方法时。调用哪个实现取决于接收者对象的运行时类,因此适合表达同一能力的多种策略:
PricingRule rule = new FixedDiscount(1_000);
long result = rule.apply(10_000); // 运行 FixedDiscount.apply,得到 9000重载则是同名、不同参数列表的方法集合。编译器根据调用点可见的声明类型选择签名:
static String describe(PricingRule rule) { return "rule"; }
static String describe(FixedDiscount rule) { return "fixed"; }
PricingRule rule = new FixedDiscount(1_000);
describe(rule); // "rule",不会按运行时类改选重载不要用重载模拟业务多态。若调用结果必须随实现变化,应使用接口方法重写;若只是为不同输入形态提供便利入口,重载才合适。重载加自动装箱、可变参数或 null 还可能制造歧义,公共 API 应避免只有“碰巧最具体”才能区分的签名。
规则用组合接入,不要先造折扣继承树
类继承同时带来“是一个”的子类型关系、可见成员和实现复用,耦合很强。只有替换原则成立时才应继承:任何接收父类型的代码,都能在不增加特殊判断的情况下接收子类型。若子类需要禁用父类操作、加强父类前置条件或改变核心语义,这个层次已经失真。
组合把能力保存为字段:Checkout 持有一个 PricingRule,对外只暴露报价行为。替换规则时,结账对象的身份和生命周期不必变。实际建模可以先用不可变值对象表达折扣事实,再用接口表达可替换能力,通过构造器把两者组合起来。只有当“子类确实可以在所有父类型调用点无条件替换”时,类继承才有意义;仅仅想复用几行实现,不足以承担父子生命周期被绑定的代价。
扩展集合是否封闭,则等到确认所有权再决定。当前模块拥有全部折扣种类,可以用 sealed 让编译器协助穷举;跨组织的插件点必须允许外部实现,就保持普通接口,并把兼容规则写进契约测试。这个顺序比一开始同时建抽象父类、接口和工厂更容易验证,也更容易撤回错误抽象。
接口不是“所有类都实现一下”的装饰。接口的价值在于定义调用者所需的最小协议,并允许实现独立演进。方法过多的接口会迫使实现依赖无关能力,也让测试替身越来越虚假。
构造成功以后,不变量还得继续成立
不可变对象在构造后不改变可观察状态,调用者可以把“创建时校验通过”当作持续事实。它减少共享引用带来的时序问题,也让缓存、并发读取和失败重试更容易推理。record 很适合承载值语义,并自动提供基于组件的访问器、equals、hashCode 和 toString。
但 record 只保证组件字段不可重新赋值,不会深拷贝可变组件:
record Batch(java.util.List<String> ids) {
Batch {
ids = java.util.List.copyOf(ids);
}
}若不做防御性复制,外部仍可通过原始列表改变 Batch 的可观察内容。不可变性的代价是更新时创建新对象和可能增加短命分配;是否构成性能问题必须用分配率、GC 与延迟证据判断,不能因为“可能创建对象”就退回共享可变状态。
可变键为什么会从 HashSet 里“消失”
== 对基本类型比较值,对引用类型比较是否指向同一对象。equals 表达逻辑相等,默认实现仍是对象身份;值对象通常需要重写它。只重写 equals 不重写 hashCode 会破坏哈希集合的查找前提:逻辑相等的对象必须有相同哈希码。
更隐蔽的问题是把可变字段纳入 equals 与 hashCode。对象加入 HashSet 后若该字段改变,它会落在旧桶中,却按新哈希查找,甚至“集合里明明有自己,contains 却为 false”。示例的正常路径会复现这个失败。生产设计中:
值对象保持不可变,并由全部稳定值定义相等性。有生命周期的实体用稳定、非空、不会被重新分配的标识定义相等性。不把数据库生成前后的临时状态、可变名称或集合放进哈希身份。
ORM 代理涉及的类比较策略必须统一验证,不能在不同实体各写一套猜测规则。
空折扣应该在哪一层失败
null 可赋给任何引用类型,却不属于任何引用类型的实例;对它调用实例方法会失败。它不能区分“字段未提供”“查询无结果”“值尚未计算”“无权限查看”这些不同语义。边界设计应先问缺失是否合理:
必需依赖在构造器用 Objects.requireNonNull 尽早拒绝,并给出领域可定位的信息。可选查询结果可在接口处使用 Optional<T>,但不要把 Optional 随意放进实体字段、序列化契约或方法参数。多种缺失原因用封闭结果类型或明确异常表达,避免一个 null 承担所有状态。
外部输入先做存在性和格式校验,再构造非空领域对象;静态类型不能净化不可信数据。
空检查越靠近错误发生点,越能保留上下文。把 null 传过五层再失败,会把“哪个边界违约”变成排障猜谜。
规则种类已知时,把漏分支变成编译问题
sealed class 或 sealed interface 明确列出允许的直接子类型;这些子类型必须继续声明为 final、sealed 或 non-sealed。它适合支付结果、命令结果、权限判定等有限状态集合,让维护者能看到完整状态空间,也让模式分支更容易做穷举审查。
sealed interface PricingRule permits FixedDiscount, RateDiscount {}
record FixedDiscount(long cents) implements PricingRule {}
record RateDiscount(int basisPoints) implements PricingRule {}封闭不是越多越安全。公共 SDK、驱动、策略插件需要第三方实现时,封闭层次会把每次扩展变成核心模块发布;此时开放接口加稳定契约更合适。相反,进程内部有限业务状态若使用一个开放接口,未知实现可能绕过审计分支。选择依据是扩展权属于谁,而不是语法新旧。
现在把正向和失败路径都跑一遍
示例位于 examples/backend-development/java/type-object-model/。在该目录执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out src/example/TypeBoundaryDemo.java
java -cp out example.TypeBoundaryDemo预期输出:
declared-overload=rule
runtime-dispatch=9000
equal-value=true
hash-lookup-before=true
hash-lookup-after=false前两行证明重载看声明类型、重写看运行时对象;第三行证明 record 的值相等;最后两行故意显示可变哈希键破坏查找。
再运行空值失败:
java -cp out example.TypeBoundaryDemo --unsafe-null进程应以非零状态退出,异常栈首个业务位置是 Checkout 构造器中的 Objects.requireNonNull。这比在 quote 深处调用 rule.apply 才失败更早、更可定位。
最后运行数组存储失败:
java -cp out example.TypeBoundaryDemo --array-store它应抛出 ArrayStoreException。若团队只看到“Object[] 能接收 String[]”便认为写入安全,这个反向实验就是直接反证。实验完成后删除 out/ 即可。
看到异常时,沿对象进入系统的方向往回查
如果线上出现 ClassCastException,不要先在报错行补强制转换。先看公共签名是不是过宽,调用者是否偷偷假设了某个具体实现,再追到 DTO、反序列化或反射入口。转换失败只告诉你错误在哪次读取被发现,不代表错误就在这里产生。
NullPointerException 也一样。反向模式 --unsafe-null 如果在 Checkout 构造器失败,说明边界守住了;如果空值一路传到 quote() 深处才爆炸,就要回看是谁允许一个“没有定价规则的结账对象”被构造出来。在最深处统一 catch 只能抹掉现场,不能修复模型。
遇到 HashSet.contains 失常,第一反应应是检查参与 equals 和 hashCode 的字段是否发生变化,而不是反复 remove/add。新增折扣类型后某个分支漏处理,则检查这个状态空间是否本应 sealed,以及代码是否用静默 default 掩盖了遗漏。异常栈给出现象位置,对象从哪道边界进入、哪些事实后来发生变化,才是排障主线。
别急着为了“少创建对象”牺牲模型
有人会担心值对象、不可变快照和接口分派增加开销,于是把参数全改成基本类型或 Object,再共享一批可变集合。这里要注意,优化前先拿证据。基本类型确实能减少装箱,但通用集合仍可能装箱;不可变快照确实会分配,却也切断了跨线程和跨请求的别名。用 JFR 看分配率、GC 和尾延迟,用 JMH 隔离热路径,确认瓶颈后再考虑基本类型数组、专用结构或批量接口,并保留与领域实现的等价性测试。
类型也不能替代安全校验。网络 DTO 仍要做允许列表、长度和范围检查,反序列化不能任意实例化实现类,敏感值对象的 toString 不能把密钥写进日志。instanceof 只证明对象属于某类,不证明当前用户有权执行操作;授权结果应该由独立策略产生,必要时再用封闭结果类型防止调用者漏处理拒绝状态。
开放接口和 sealed 的取舍仍回到扩展权。若折扣插件由其他团队独立发布,开放接口更合适,但契约兼容责任会落到所有实现者身上;若规则就是当前模块拥有的有限业务状态,封闭层次能让新增和删除变成显式影响。组合多一层对象,却把结账生命周期与折扣算法分开;继承少写几行转发,却可能把两个变化轴绑在一起。
把这次故障变成评审和 CI 的证据
回到开头那次事故,评审时不要再问一句泛泛的“类型设计是否合理”。让作者指出:DTO 在哪里停止,范围和非空校验在哪里建立,哪些字段定义值相等,实体身份何时稳定,谁拥有新增 PricingRule 的权利。若使用继承,还要拿一个接收父类型的真实调用点证明子类可以无条件替换,而不是只说“为了复用”。
这些判断可以落进测试。正常示例固定用 javac --release 17 编译并断言输出;哈希测试先加入集合、再执行所有允许的状态变化,确认查找仍成立;每种封闭状态都跑一条行为断言;反序列化测试绕过常规构造路径时,必须证明补偿校验仍会拒绝非法值。重构造成的编译失败是影响清单,不要用扩大类型或 unchecked 转换把它消掉。
把模型门禁写成可观察的不变量,比统计 DTO、record 或接口数量更可靠:
| 边界 | 自动化证据 | 通过条件 |
|---|---|---|
| 外部 DTO → 领域对象 | 边界契约测试与非法样本 | 非法范围、缺失值在构造边界失败,不能生成半合法对象 |
| 值对象进入集合 | 相等性/哈希性质测试 | 所有允许操作后 equals 与 hashCode 仍一致;作为键的字段不可漂移 |
| 有限规则集合 | 每个 permitted subtype 的行为测试 | 新增规则会触发编译影响或缺失用例,不能被静默 default 吞掉 |
| 开放插件接口 | 旧实现二进制与契约样本 | 旧实现无需重编译仍可加载,前置/后置条件不变 |
| 性能优化替代实现 | 同输入等价性测试与 JFR/JMH | 业务结果完全等价,且目标分配率或尾延迟确有改善 |
这里没有要求所有模型都使用 record 或 sealed。门禁约束的是不变量能否被构造、保持和验证;语法只是当前所有权与扩展方式下的一种实现。
做到这里,这个报价模型才算站稳:外部载荷不能冒充领域对象,构造完成后不变量持续成立,集合身份不会漂移,运行时策略由接口分派,有限状态由编译或测试穷举。对象布局、类加载和泛型推断不会改变这些领域不变量的所有权,只会提供另外几层运行时证据。
类型语义不确定时查这些入口
Java Language Specification 25:第 4 章 Types, Values, and Variables。JLS 25:第 5 章 Conversions and Contexts
JLS 25:第 8 章 Classes(重写、重载、record、sealed)。JLS 25:第 9 章 Interfaces。JLS 25:第 10 章 Arrays
Java SE 25 API:java.lang.Object。Java SE 25 API:java.util.Objects
