diff --git a/storm-core/src/main/java/module-info.java b/storm-core/src/main/java/module-info.java
index 1a30231f2..90ef0b324 100644
--- a/storm-core/src/main/java/module-info.java
+++ b/storm-core/src/main/java/module-info.java
@@ -8,6 +8,7 @@
uses st.orm.core.spi.ConnectionProvider;
uses st.orm.core.spi.TransactionTemplateProvider;
uses st.orm.core.spi.CursorCodecProvider;
+ uses st.orm.mapping.Instantiator;
exports st.orm.core.template;
exports st.orm.core.template.impl;
exports st.orm.core.spi;
diff --git a/storm-core/src/main/java/st/orm/core/spi/Instantiators.java b/storm-core/src/main/java/st/orm/core/spi/Instantiators.java
new file mode 100644
index 000000000..117548171
--- /dev/null
+++ b/storm-core/src/main/java/st/orm/core/spi/Instantiators.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2024 - 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package st.orm.core.spi;
+
+import static java.lang.Thread.currentThread;
+import static java.util.Optional.ofNullable;
+import static java.util.ServiceLoader.load;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.ServiceLoader;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import st.orm.mapping.Instantiator;
+
+/**
+ * Registry of generated {@link Instantiator} implementations, discovered through the {@link ServiceLoader}.
+ *
+ *
The metamodel generators register an instantiator per record type in
+ * {@code META-INF/services/st.orm.mapping.Instantiator}. The registry is loaded once per class loader and consulted
+ * by the row mapper to construct records without reflection; types without a registered instantiator fall back to
+ * reflective construction.
+ */
+public final class Instantiators {
+
+ /** Instantiators per class loader, keyed by the record type they construct. */
+ private static final ConcurrentMap, Instantiator>>> INSTANTIATOR_CACHE =
+ new ConcurrentHashMap<>();
+
+ private Instantiators() {
+ }
+
+ /**
+ * Returns the instantiator registered for the given record type, or {@code null} if none is registered.
+ *
+ * @param type the record type to find an instantiator for.
+ * @param the record type.
+ * @return the registered instantiator, or {@code null} if the type has no generated instantiator.
+ */
+ @Nullable
+ @SuppressWarnings("unchecked")
+ public static Instantiator find(@Nonnull Class type) {
+ ClassLoader classLoader = ofNullable(currentThread().getContextClassLoader())
+ .orElseGet(() -> Instantiators.class.getClassLoader());
+ return (Instantiator) INSTANTIATOR_CACHE
+ .computeIfAbsent(classLoader, Instantiators::loadInstantiators)
+ .get(type);
+ }
+
+ private static Map, Instantiator>> loadInstantiators(@Nonnull ClassLoader classLoader) {
+ Map, Instantiator>> instantiators = new HashMap<>();
+ for (Instantiator> instantiator : load(Instantiator.class, classLoader)) {
+ instantiators.put(instantiator.type(), instantiator);
+ }
+ if (instantiators.isEmpty() && classLoader != Instantiators.class.getClassLoader()) {
+ // Revert to the registry's class loader, matching the provider loading strategy.
+ for (Instantiator> instantiator : load(Instantiator.class, Instantiators.class.getClassLoader())) {
+ instantiators.put(instantiator.type(), instantiator);
+ }
+ }
+ return Map.copyOf(instantiators);
+ }
+}
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java
index f8fec1546..3427e7a17 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java
@@ -22,10 +22,12 @@
import static st.orm.core.template.impl.RecordValidation.validateDataType;
import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Parameter;
+import java.lang.reflect.RecordComponent;
import java.util.BitSet;
import java.util.Map;
import java.util.Optional;
@@ -33,10 +35,12 @@
import java.util.function.Supplier;
import st.orm.Data;
import st.orm.PK;
+import st.orm.core.spi.Instantiators;
import st.orm.core.spi.ORMReflection;
import st.orm.core.spi.Providers;
import st.orm.core.spi.RefFactory;
import st.orm.core.template.SqlTemplateException;
+import st.orm.mapping.Instantiator;
/**
* Factory for creating instances of a specific type.
@@ -177,6 +181,12 @@ static T construct(@Nonnull Constructor constructor, @Nonnull Object[] ar
}
}
}
+ var instantiator = meta.instantiator();
+ if (instantiator != null) {
+ // Generated instantiator: constructs through the canonical constructor without reflection.
+ //noinspection unchecked
+ return (T) instantiator.instantiate(args);
+ }
try {
// Use the map's constructor instance: its accessible flag was set before publication, so the
// happens-before edge of the concurrent map makes the flag visible to all threads.
@@ -199,11 +209,13 @@ static T construct(@Nonnull Constructor constructor, @Nonnull Object[] ar
* @param parameterNames the parameter names, for error messages.
* @param nonNull whether each parameter is marked as non-null.
* @param primitive whether each parameter is a primitive type.
+ * @param instantiator the generated instantiator for the constructor, or null to construct reflectively.
*/
private record ConstructorMeta(@Nonnull Constructor> constructor,
@Nonnull String[] parameterNames,
@Nonnull boolean[] nonNull,
- @Nonnull boolean[] primitive) {}
+ @Nonnull boolean[] primitive,
+ @Nullable Instantiator> instantiator) {}
/** Cache of precomputed constructor metadata, keyed by constructor. Thread-safe for concurrent access. */
private static final Map, ConstructorMeta> CONSTRUCTOR_META = new ConcurrentHashMap<>();
@@ -220,7 +232,38 @@ private static ConstructorMeta constructorMeta(@Nonnull Constructor> construct
primitive[i] = parameterTypes[i].isPrimitive();
}
constructor.setAccessible(true);
- return new ConstructorMeta(constructor, parameterNames, nonNull, primitive);
+ return new ConstructorMeta(constructor, parameterNames, nonNull, primitive, findInstantiator(constructor));
+ }
+
+ /**
+ * Returns the generated instantiator for the given constructor, or {@code null} if none is registered or the
+ * constructor is not the one the instantiator was generated for.
+ *
+ * Instantiators are generated for the canonical constructor of records and the primary constructor of
+ * Kotlin data classes. For records the constructor is verified against the record components; for non-record
+ * classes the registered instantiator is trusted, as the reflection provider resolves the primary
+ * constructor.
+ */
+ @Nullable
+ private static Instantiator> findInstantiator(@Nonnull Constructor> constructor) {
+ Class> declaringClass = constructor.getDeclaringClass();
+ Instantiator> instantiator = Instantiators.find(declaringClass);
+ if (instantiator == null) {
+ return null;
+ }
+ if (declaringClass.isRecord()) {
+ RecordComponent[] components = declaringClass.getRecordComponents();
+ Class>[] parameterTypes = constructor.getParameterTypes();
+ if (components.length != parameterTypes.length) {
+ return null;
+ }
+ for (int i = 0; i < components.length; i++) {
+ if (components[i].getType() != parameterTypes[i]) {
+ return null;
+ }
+ }
+ }
+ return instantiator;
}
@SuppressWarnings("unchecked")
diff --git a/storm-core/src/test/java/st/orm/core/InstantiatorIntegrationTest.java b/storm-core/src/test/java/st/orm/core/InstantiatorIntegrationTest.java
new file mode 100644
index 000000000..d9f86def6
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/InstantiatorIntegrationTest.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2024 - 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package st.orm.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.junit.jupiter.api.Test;
+import st.orm.core.model.Address;
+import st.orm.core.model.City;
+import st.orm.core.model.Owner;
+import st.orm.core.model.Pet;
+import st.orm.core.spi.Instantiators;
+
+/**
+ * Verifies that the metamodel processor generates and registers {@link st.orm.mapping.Instantiator}
+ * implementations for the test models, and that they construct instances equivalent to the canonical constructor.
+ * The full test suite exercises these instantiators implicitly, as the row mapper dispatches to them instead of
+ * reflective construction.
+ */
+public class InstantiatorIntegrationTest {
+
+ @Test
+ public void testInstantiatorsAreRegisteredForModelRecords() {
+ assertNotNull(Instantiators.find(Pet.class));
+ assertNotNull(Instantiators.find(Owner.class));
+ assertNotNull(Instantiators.find(City.class));
+ // Plain nested records (no Data interface) are covered through the referenced-record expansion.
+ assertNotNull(Instantiators.find(Address.class));
+ }
+
+ @Test
+ public void testInstantiatorConstructsEquivalentInstance() {
+ var instantiator = Instantiators.find(City.class);
+ assertNotNull(instantiator);
+ assertSame(City.class, instantiator.type());
+ City city = instantiator.instantiate(new Object[] { 42, "Rotterdam" });
+ assertEquals(new City(42, "Rotterdam"), city);
+ }
+
+ @Test
+ public void testUnregisteredTypeFallsBackToNull() {
+ assertNull(Instantiators.find(String.class));
+ }
+}
diff --git a/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java b/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java
new file mode 100644
index 000000000..fae151b97
--- /dev/null
+++ b/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 - 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package st.orm.mapping;
+
+import jakarta.annotation.Nonnull;
+
+/**
+ * Constructs record instances without reflection.
+ *
+ * The metamodel generators emit an implementation per record type that invokes the record's canonical
+ * constructor (the primary constructor for Kotlin data classes) directly, registered through
+ * {@code META-INF/services/st.orm.mapping.Instantiator}. The row mapper dispatches to a registered instantiator
+ * instead of {@code Constructor.newInstance}, so applications run without reflective construction: no reflection
+ * configuration for native images, and no {@code opens} clauses for modular applications.
+ *
+ * When no instantiator is registered for a type, the row mapper falls back to reflective construction, so
+ * models compiled without the generators keep working unchanged.
+ *
+ * @param the record type this instantiator constructs.
+ * @since 1.13
+ */
+public interface Instantiator {
+
+ /**
+ * Returns the record type this instantiator constructs.
+ *
+ * @return the constructed record type.
+ */
+ Class type();
+
+ /**
+ * Constructs a new instance from the canonical constructor arguments.
+ *
+ * The arguments are positional and fully adapted: the caller has already performed null checks and type
+ * conversion, so implementations only cast and invoke the constructor.
+ *
+ * @param args the canonical constructor arguments, in declaration order.
+ * @return the constructed instance.
+ */
+ T instantiate(@Nonnull Object[] args);
+}
diff --git a/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt b/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt
index 83ce7f46f..c0c30f84d 100644
--- a/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt
+++ b/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt
@@ -59,6 +59,12 @@ class MetamodelProcessor(
*/
private val expandedReferencedTypes = mutableSetOf()
+ /**
+ * Fully qualified names of the generated instantiators with their originating files, registered as services
+ * when processing finishes.
+ */
+ private val generatedInstantiators = mutableListOf>()
+
companion object {
private const val GENERATE_METAMODEL = "st.orm.GenerateMetamodel"
private const val DATA = "st.orm.Data"
@@ -949,6 +955,7 @@ class MetamodelProcessor(
if (processedClasses.add(qualifiedName)) {
generateMetamodelClass(classDeclaration, forceNullableChain = false)
generateMetamodelClass(classDeclaration, forceNullableChain = true)
+ generateInstantiator(classDeclaration)
}
// Only generate interface if this type implements Data.
@@ -962,6 +969,80 @@ class MetamodelProcessor(
}
}
+ /**
+ * Generates an `Instantiator` implementation that invokes the data class's primary constructor directly,
+ * allowing the runtime to construct instances without reflection (no reflection configuration for native
+ * images, no `opens` clauses for modular applications).
+ *
+ * Only top-level, non-generic data classes are supported; other types fall back to reflective construction
+ * at runtime.
+ */
+ private fun generateInstantiator(classDeclaration: KSClassDeclaration) {
+ if (!classDeclaration.isDataClass() ||
+ classDeclaration.parentDeclaration != null ||
+ classDeclaration.typeParameters.isNotEmpty()
+ ) {
+ return
+ }
+ val primaryConstructor = classDeclaration.primaryConstructor ?: return
+ val packageName = classDeclaration.packageName.asString()
+ val className = classDeclaration.simpleName.asString()
+ val instantiatorName = "${className}Instantiator"
+ val arguments = primaryConstructor.parameters.mapIndexed { index, parameter ->
+ " args[$index] as ${getKotlinValueTypeName(parameter.type, packageName)}"
+ }.joinToString(",\n")
+ val containingFile = classDeclaration.containingFile
+ val deps = if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false)
+ val file = codeGenerator.createNewFile(
+ dependencies = deps,
+ packageName = packageName,
+ fileName = instantiatorName,
+ )
+ OutputStreamWriter(file).use { writer ->
+ writer.write(
+ """
+ |${if (packageName.isEmpty()) "" else "package $packageName\n"}
+ |import javax.annotation.processing.Generated
+ |
+ |/**
+ | * Instantiator for $className; constructs instances without reflection.
+ | */
+ |@Generated("st.orm.metamodel.MetamodelProcessor")
+ |class $instantiatorName : st.orm.mapping.Instantiator<$className> {
+ |
+ | override fun type(): Class<$className> = $className::class.java
+ |
+ | @Suppress("UNCHECKED_CAST")
+ | override fun instantiate(args: Array): $className = $className(
+ |$arguments
+ | )
+ |}
+ """.trimMargin(),
+ )
+ }
+ val qualifiedInstantiatorName = if (packageName.isEmpty()) instantiatorName else "$packageName.$instantiatorName"
+ generatedInstantiators.add(qualifiedInstantiatorName to containingFile)
+ }
+
+ /**
+ * Registers the generated instantiators in `META-INF/services/st.orm.mapping.Instantiator`, allowing the
+ * runtime to discover them through the `ServiceLoader` and construct records without reflection.
+ */
+ override fun finish() {
+ if (generatedInstantiators.isEmpty()) {
+ return
+ }
+ val originatingFiles = generatedInstantiators.mapNotNull { it.second }.distinct()
+ val file = codeGenerator.createNewFileByPath(
+ dependencies = Dependencies(true, *originatingFiles.toTypedArray()),
+ path = "META-INF/services/st.orm.mapping.Instantiator",
+ extensionName = "",
+ )
+ OutputStreamWriter(file).use { writer ->
+ generatedInstantiators.forEach { (name, _) -> writer.write("$name\n") }
+ }
+ }
+
private fun generateMetamodelInterface(classDeclaration: KSClassDeclaration, resolver: Resolver) {
val packageName = classDeclaration.packageName.asString()
val className = classDeclaration.simpleName.asString()
diff --git a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java
index 2c871fee2..b99ef4cc9 100644
--- a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java
+++ b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java
@@ -29,6 +29,7 @@
import jakarta.annotation.Nullable;
import java.io.Writer;
import java.util.HashSet;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -54,7 +55,9 @@
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.Elements;
import javax.lang.model.util.Types;
+import javax.tools.FileObject;
import javax.tools.JavaFileObject;
+import javax.tools.StandardLocation;
/**
* @since 1.2
@@ -88,6 +91,11 @@ public final class MetamodelProcessor extends AbstractProcessor {
*/
private final Set expandedReferencedRecords;
+ /**
+ * Fully qualified names of the generated instantiators, registered as services when processing is over.
+ */
+ private final Set generatedInstantiators;
+
private Elements elementUtils;
private Types typeUtils;
@@ -95,6 +103,7 @@ public MetamodelProcessor() {
this.generatedMetamodelClasses = new HashSet<>();
this.generatedMetamodelInterfaces = new HashSet<>();
this.expandedReferencedRecords = new HashSet<>();
+ this.generatedInstantiators = new LinkedHashSet<>();
}
@Override
@@ -271,9 +280,34 @@ && implementsData(element)) {
} catch (Exception e) {
processingEnv.getMessager().printMessage(ERROR, "Failed to process metamodel. Error: " + e);
}
+ if (roundEnv.processingOver()) {
+ writeInstantiatorServices();
+ }
return false;
}
+ /**
+ * Registers the generated instantiators in {@code META-INF/services/st.orm.mapping.Instantiator}, allowing the
+ * runtime to discover them through the {@code ServiceLoader} and construct records without reflection.
+ */
+ private void writeInstantiatorServices() {
+ if (generatedInstantiators.isEmpty()) {
+ return;
+ }
+ try {
+ FileObject fileObject = processingEnv.getFiler()
+ .createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/services/st.orm.mapping.Instantiator");
+ try (Writer writer = fileObject.openWriter()) {
+ for (String instantiator : generatedInstantiators) {
+ writer.write(instantiator);
+ writer.write("\n");
+ }
+ }
+ } catch (Exception e) {
+ processingEnv.getMessager().printMessage(ERROR, "Failed to write instantiator services file. Error: " + e + ".");
+ }
+ }
+
/**
* Walk record-typed fields on this record and ensure metamodels exist for referenced record types.
*
@@ -318,6 +352,7 @@ private void generateMetamodelArtifacts(@Nonnull Element recordElement) {
// Always generate the class once.
if (generatedMetamodelClasses.add(qn)) {
generateMetamodelClass(recordElement);
+ generateInstantiator(recordElement);
}
// Only generate the interface for Data records.
@@ -795,6 +830,82 @@ private String buildInterfaceFields(@Nonnull Element recordElement, @Nonnull Str
return builder.toString();
}
+ /**
+ * Generates an {@code Instantiator} implementation that invokes the record's canonical constructor directly,
+ * allowing the runtime to construct instances without reflection (no reflection configuration for native
+ * images, no {@code opens} clauses for modular applications).
+ *
+ * Only top-level, non-generic Java records are supported; other types fall back to reflective
+ * construction at runtime.
+ */
+ private void generateInstantiator(@Nonnull Element recordElement) {
+ if (recordElement.getKind() != RECORD) {
+ return; // Kotlin classes are handled by the KSP processor.
+ }
+ TypeElement typeElement = asTypeElement(recordElement.asType());
+ if (typeElement == null
+ || typeElement.getEnclosingElement().getKind() != ElementKind.PACKAGE
+ || !typeElement.getTypeParameters().isEmpty()) {
+ return;
+ }
+ ExecutableElement constructor = findCanonicalConstructor(recordElement);
+ if (constructor == null) {
+ return;
+ }
+ String packageName = elementUtils.getPackageOf(recordElement).getQualifiedName().toString();
+ String recordName = recordElement.getSimpleName().toString();
+ String instantiatorName = recordName + "Instantiator";
+ var parameters = constructor.getParameters();
+ StringBuilder arguments = new StringBuilder();
+ for (int i = 0; i < parameters.size(); i++) {
+ String castType = getBoxedTypeName(parameters.get(i).asType().toString().replaceAll("@\\S+\\s+", ""));
+ arguments.append(arguments.isEmpty() ? "" : ",\n")
+ .append(" (").append(castType).append(") args[").append(i).append("]");
+ }
+ try {
+ JavaFileObject fileObject = processingEnv.getFiler()
+ .createSourceFile((packageName.isEmpty() ? "" : packageName + ".") + instantiatorName, recordElement);
+ try (Writer writer = fileObject.openWriter()) {
+ writer.write(String.format("""
+ %simport javax.annotation.processing.Generated;
+
+ /**
+ * Instantiator for %s; constructs instances without reflection.
+ */
+ @Generated("%s")
+ public final class %s implements st.orm.mapping.Instantiator<%s> {
+
+ @Override
+ public Class<%s> type() {
+ return %s.class;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public %s instantiate(Object[] args) {
+ return new %s(
+ %s
+ );
+ }
+ }""",
+ (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n"),
+ recordName,
+ getClass().getName(),
+ instantiatorName,
+ recordName,
+ recordName,
+ recordName,
+ recordName,
+ recordName,
+ arguments
+ ));
+ }
+ generatedInstantiators.add((packageName.isEmpty() ? "" : packageName + ".") + instantiatorName);
+ } catch (Exception e) {
+ processingEnv.getMessager().printMessage(ERROR, "Failed to process " + instantiatorName + ". Error: " + e + ".");
+ }
+ }
+
private void generateMetamodelInterface(@Nonnull Element recordElement) {
TypeElement typeElement = asTypeElement(recordElement.asType());
if (typeElement == null) return;