Repository navigation
Conversation
A value class has no identity, and its instances cannot be created empty and populated afterwards - which is exactly what the reflective path does: create blank, write each field, register the instance under an object id. Every step of that fails for a value, so value classes were unpersistable. This adds a path of their own, from the type handler down to the binary layout, and re-composes the JDK types that the language change turns into value classes. Handlers. BinaryHandlerGenericValueClass and BinaryLegacyTypeHandlerValueClass construct the instance from its persisted state instead of populating one, and BinaryTypeHandlerCreator routes value classes away from the reflective branch. A field whose type is a value class may be laid out inside its owner, so it is reached through a handle rather than by its memory offset - on the current, legacy and enum paths alike. Constructor contract. Constructing from stored state is the inverse of storing only if the constructor assigns each argument to the matching field unmodified. ValueClassConstructorContract verifies that by observing what the constructor does: at handler creation, or against the first instance stored where that cannot answer. Two same-typed fields taken in the wrong order, or a field computed from its argument, are refused instead of drifting silently on every round trip. Value inlining. A field whose type has no identity costs an entity, and an object id, per owner on every store. PersistenceValueInliningResolver decides per field whether the value is written into the owner's own binary form instead: a null marker followed by the field type's layout, described in the type dictionary by the new member kind PersistenceTypeDescriptionMemberFieldValueStruct. The default inlines what the application itself declares; setValueInliningResolver and Disabled() select otherwise. An inlined slot carries no type information of its own, so an abstract declared type, a reference inside the layout and a constructor that cannot be invoked are refused rather than inlined. Evolution. An inlined layout evolves like any other: members added, removed, renamed or translated, nested value classes included, with the shapes that cannot be told apart refused rather than guessed. Identity consequences. PersistenceStoreHandler#applyKnown routes a reference by an object id an earlier store established, so storing a reference list no longer re-assigns ids to unchanged value referents. A lazy reference keeps its link to a value referent. A root refuses a value instance, loading a root meaning that the persisted state is applied to the very instance that was set. JDK types. The java.time types and Optional are now composed from their persisted state through AbstractBinaryHandlerCustomComposedValueType, and the wrapper constants registered at startup are gated on whether the JDK has migrated them.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Several value-root, constructor-validation, and non-value-JVM compatibility paths can lose identity or fail loading.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 4
Open (5)
Resolve wrapper constants on the eager root-reference path · New Gate value-class handler replacements on runtime support · New Fail fast when value classes lack accessible constructors · New Use the persisted-state bypass when migrating custom roots · New Reject non-identical rebuilt references for identity-typed fields · New
What changed in this PR
Adds JEP 401 value-class persistence, including constructor-based loading, field inlining, schema evolution, and adapted JDK handlers.
Changes:
- Introduces value-class detection, handlers, deferred creation, and constructor validation.
- Adds inline value layouts to binary storage and type dictionaries.
- Updates roots, constants, lazy references, and
java.time/Optionalhandling.
| File | Description |
|---|---|
Serializer.java |
Adapts eager and skipped storage behavior. |
PersistenceValueInliningResolver.java |
Defines value-field inlining policy. |
PersistenceTypeHandler.java |
Adds value and deferred-creation contracts. |
PersistenceTypeDictionaryParser.java |
Parses inline value layouts. |
PersistenceTypeDictionary.java |
Adds the value dictionary keyword. |
PersistenceTypeDescriptionMemberFieldValueStruct.java |
Models inline value descriptions. |
PersistenceTypeDescriptionMemberAppender.java |
Serializes inline dictionary entries. |
PersistenceTypeDescriptionMember.java |
Includes lengths in layout equality. |
PersistenceTypeDefinitionMemberFieldValueStruct.java |
Adds runtime-bound inline definitions. |
PersistenceTypeDefinitionMemberCreator.java |
Creates runtime inline definitions. |
PersistenceStoreHandler.java |
Adds known-ID reuse and eager-state APIs. |
PersistenceRootReference.java |
Validates explicitly assigned roots. |
PersistenceObjectManager.java |
Resolves value-based JDK constants. |
PersistenceManager.java |
Adds arithmetic constant lookup. |
PersistenceLegacyTypeHandlerWrapper.java |
Delegates new handler lifecycle methods. |
PersistenceLegacyTypeHandlerCreator.java |
Routes value classes away from reflective legacy loading. |
PersistenceFoundation.java |
Configures value-field inlining. |
Persistence.java |
Adds arithmetic wrapper-constant mappings. |
ValueClassConstructorContract.java |
Validates constructor-field correspondence. |
BinaryValueTranslatorProvider.java |
Translates inline and value-typed fields. |
BinaryValueReader.java |
Reads persisted constructor arguments. |
BinaryTypeHandlerCreator.java |
Selects value-class handlers. |
BinaryStorer.java |
Reuses known IDs and excludes value instances from merging. |
BinaryReferenceTraverser.java |
Skips reference-free inline layouts. |
BinaryPersistenceFoundation.java |
Wires inlining and refactoring dependencies. |
BinaryPersistence.java |
Registers new JDK value-compatible handlers. |
BinaryLoadItem.java |
Tracks deferred creation state. |
BinaryLoader.java |
Implements dependency-ordered deferred creation. |
BinaryLegacyTypeHandlerValueClass.java |
Loads evolved value classes by construction. |
BinaryLegacyTypeHandlerRerouting.java |
Moves legacy rewriting before reference traversal. |
BinaryLegacyTypeHandlerCreator.java |
Creates value-specific legacy handlers. |
BinaryHandlerGenericValueClass.java |
Implements generic value-class persistence. |
BinaryHandlerGenericType.java |
Passes through the inlining resolver. |
BinaryHandlerGenericEnum.java |
Handles value-typed enum fields safely. |
Binary.java |
Adds known-reference list storage. |
AbstractBinaryLegacyTypeHandlerTranslating.java |
Delegates value/deferred traits. |
AbstractBinaryHandlerReflective.java |
Integrates inline fields into reflective layouts. |
AbstractBinaryHandlerCustomComposedValueType.java |
Supports types constructed from referenced parts. |
BinaryHandlerLazyDefault.java |
Preserves links to immutable value referents. |
BinaryHandlerRootReferenceDefault.java |
Handles loaded value roots. |
BinaryHandlerPersistenceRootsDefault.java |
Avoids registering value roots. |
BinaryHandlerEntityLoading.java |
Delegates new load lifecycle methods. |
BinaryHandlerOptional.java |
Constructs Optional from persisted content. |
BinaryHandlerZoneRegion.java |
Reconstructs region-based zones. |
BinaryHandlerZonedDateTime.java |
Reconstructs zoned date-times. |
BinaryHandlerYearMonth.java |
Validates immutable persisted state. |
BinaryHandlerYear.java |
Adds constructor-based Year handling. |
BinaryHandlerPeriod.java |
Validates immutable persisted state. |
BinaryHandlerOffsetTime.java |
Reconstructs offset times. |
BinaryHandlerOffsetDateTime.java |
Reconstructs offset date-times. |
BinaryHandlerMonthDay.java |
Validates immutable persisted state. |
BinaryHandlerLocalTime.java |
Adds constructor-based local-time handling. |
BinaryHandlerLocalDateTime.java |
Reconstructs local date-times. |
BinaryHandlerLocalDate.java |
Validates immutable persisted state. |
BinaryHandlerInstant.java |
Adds Instant handling. |
BinaryHandlerDuration.java |
Adds Duration handling. |
AbstractBinaryHandlerSecondsNanos.java |
Shares seconds/nanos persistence logic. |
XTypes.java |
Clarifies policy-value versus JVM-value semantics. |
ValueType.java |
Documents its relationship to JEP 401. |
XReflect.java |
Detects value classes across JDK versions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
LocalTime.of answers one shared instance per whole hour, Instant.ofEpochSecond one for the epoch and Duration.ofSeconds one for zero. Building an instance from the persisted state is what a value class needs and what an identity class must not have: a storage written before these handlers existed can hold two separately obtained objects of such a value, and one instance built for both of them cannot be registered under both their object ids, so the storage does not open at all. The three handlers therefore move to #platformDependentHandlers and are registered per type, only where the JDK has migrated that type. Where it keeps its identity, the generic handler populates one instance per entity, as before.
The legacy root refactoring installs a root it has just loaded through #set, which delegates to #setRoot and therefore rejects a value instance - the very case the validation's own documentation exempts, a loaded root having no state applied to it. Such a storage does not open at all, and advises a change to data that is already written. It now uses #setRootSupplier, like the loaded default root in the branch below it and like the root reference handler. The supplier's javadoc states that this is the installation path for a root resolved from persisted state, and why that one does not validate.
fh-ms
approved these changes
Sep 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


A value class has no identity, and its instances cannot be created empty and populated
afterwards - which is what the reflective path does. This adds a persistence path of their
own, from the type handler down to the binary layout.
What it adds
BinaryHandlerGenericValueClass/BinaryLegacyTypeHandlerValueClass: the instance isconstructed from its persisted state instead of populated. A field the JVM lays out inside
its owner is reached through a handle rather than by its memory offset.
ValueClassConstructorContract: verifies that the constructor stores what it is given, athandler creation or against the first instance stored. Two same-typed fields taken in the
wrong order, or a field computed from its argument, are refused rather than silently
corrupting every round trip.
binary form - a null marker plus the field type's layout - instead of costing an entity and
an object id per owner. Described in the type dictionary by the new member kind
PersistenceTypeDescriptionMemberFieldValueStruct. The default inlines what the applicationdeclares;
PersistenceFoundation#setValueInliningResolverandPersistenceValueInliningResolver.Disabled()select otherwise. An inlined layout evolveslike any other (members added, removed, renamed, translated, nested value classes included).
PersistenceStoreHandler#applyKnown: reference an element by an object id an earlier storeestablished, so unchanged value referents are not re-assigned new ids.
java.timetypes andOptionalare composed from their persisted state, and the JDKwrapper constants registered at startup are gated on whether the JDK has migrated them.
Compatibility
On a JDK without value classes nothing reports as one, so no handler, layout or type
dictionary entry changes, and existing data is unaffected. A dictionary that does contain an
inlined member cannot be read by an older version, and an inlined slot cannot go back to being
a reference - inlining is a one-way decision for data already written.