Skip to content

feat: construct records through generated instantiators instead of reflection#255

Merged
zantvoort merged 1 commit into
mainfrom
feat/generated-instantiators
Jul 14, 2026
Merged

feat: construct records through generated instantiators instead of reflection#255
zantvoort merged 1 commit into
mainfrom
feat/generated-instantiators

Conversation

@zantvoort

Copy link
Copy Markdown
Collaborator

Implements #253: the metamodel generators emit an Instantiator per record type that invokes the canonical constructor directly, and the row mapper dispatches to it instead of Constructor.newInstance. This removes the last reflective call from the row mapping hot path. The motivation is portability, not performance: native images need no reflection configuration for entity records, and modular applications need no opens clauses for their model packages.

Contract

st.orm.mapping.Instantiator<T> (storm-foundation): type() plus instantiate(Object[] args). It lives in foundation for the same reason the metamodel types do — generated code must compile in model-only modules whose only Storm dependency is storm-foundation. It sits in st.orm.mapping rather than the root package because users never reference it in application code: the root package is user vocabulary, .mapping is machinery.

Runtime

  • Instantiators (core spi) discovers implementations through the ServiceLoader — automatically converted to build-time registrations by native-image — with the same dual class loader strategy as Providers, cached per class loader.
  • ObjectMapperFactory resolves the instantiator once per constructor into the cached ConstructorMeta and dispatches to it in construct(). The precomputed null diagnostics run in front of both paths, so error messages are unchanged.
  • For records, the constructor is verified against the record components before an instantiator is attached: non-canonical constructors always construct reflectively. Types without a registered instantiator fall back to reflection, so models compiled without the generators keep working unchanged.
  • module-info declares uses st.orm.mapping.Instantiator. Modular applications register their generated instantiators with a provides clause instead of opening the model package.

Generators

  • The annotation processor emits <Name>Instantiator alongside each metamodel for top-level, non-generic Java records and writes META-INF/services/st.orm.mapping.Instantiator when processing is over.
  • The KSP processor does the same for top-level, non-generic Kotlin data classes (casts render through the existing type-name machinery, covering generics like Ref<PetType> and nullable parameters), registering the services file in finish().
  • Everything outside those bounds (nested, generic) is skipped and constructs reflectively.

Testing

  • The annotation processor runs during storm-core's own test compilation, so the full suite exercises generated instantiators for every test model, including plain nested records reached through the referenced-record expansion. InstantiatorIntegrationTest pins registration, equivalent construction, and the null fallback explicitly.
  • The KSP path was verified end to end through the Add a reproducible benchmark suite #214 benchmark suite's Gradle build: instantiators generated for all five entities and the suite runs green through them (storm-kotlin's Maven build does not run KSP).
  • Suites: storm-core 2267, storm-kotlin 1627, storm-java21 517.

Notes

Claiming native-image compatibility in the documentation should wait for an actual GraalVM build against a sample application; this PR delivers the mechanism. Fused typed decoding (generated code reading typed columns straight into constructor parameters) remains a non-goal, per the issue.

Closes #253

…flection

The metamodel generators now emit an Instantiator implementation per
record type that invokes the canonical constructor (the primary
constructor for Kotlin data classes) directly, registered through
META-INF/services/st.orm.mapping.Instantiator. The row mapper resolves
the instantiator once per constructor and dispatches to it instead of
Constructor.newInstance, falling back to reflective construction for
types without one, so models compiled without the generators keep
working unchanged.

This removes the last reflective call from the row mapping hot path.
The motivation is portability rather than performance: native images
need no reflection configuration for entity records, and modular
applications need no opens clauses for their model packages.

The public st.orm.mapping.Instantiator contract lives in
storm-foundation so that generated code compiles in model-only modules,
mirroring the metamodel types. The ServiceLoader registry caches per
class loader; the record mapper verifies the constructor against the
record components before attaching an instantiator, so non-canonical
constructors always construct reflectively. Only top-level, non-generic
records and data classes get instantiators; the annotation processor
registers the services file when processing is over, the KSP processor
on finish.

Closes #253
@zantvoort zantvoort merged commit bedf28d into main Jul 14, 2026
7 checks passed
@zantvoort zantvoort deleted the feat/generated-instantiators branch July 14, 2026 07:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reflection-free entity instantiation via generated metamodel factories

1 participant