Removing Reflection from TeaQL Java's Core Path: Runtime Determinism as Harness Engineering
TeaQL Java did not remove every use of reflection from the repository. We did something more practical: we removed reflection from the core entity execution path, isolated the remaining reflective utilities, and added build-time guardrails to stop reflection from quietly returning.
GraalVM Native Image was an important forcing function, but it was not the highest-level reason for the work. The larger goal was to make the runtime environment more deterministic: important behavior should be explicit, inspectable, bounded, and enforceable before a production request reaches it.
We see that as harness engineering. The reliability of a system should not depend only on developers or coding agents remembering the right conventions. The surrounding environment—generated code, metadata, module boundaries, compiler-visible calls, build rules, and tests—should constrain execution into known-good paths.
Reflection Was a Source of Runtime Uncertainty
Reflection is not inherently wrong. It is one of Java's most useful escape hatches. The problem is what happens when a framework's ordinary execution depends on it.
A reflective call can make behavior depend on facts that are difficult to see at the call site:
- which concrete classes happen to be present on the classpath;
- which member name is assembled from a string;
- whether that member remains accessible under a module or runtime policy;
- whether a serializer discovers a getter, setter, field, or constructor;
- whether an AOT compiler can prove that the discovered code is reachable;
- whether external reflection metadata has stayed aligned with generated code.
Each uncertainty is manageable by itself. Together, on the framework's busiest path, they produce an execution environment whose real contract is larger than its source-level API.
Our goal was not to eliminate dynamism. TeaQL still accepts a property name at runtime, resolves entity metadata, and supports pluggable providers. The goal was to bound that dynamism with an explicit harness:
The property value can be dynamic, but the legal branches are generated and finite. The requested entity type can vary, but its constructor must be registered. JSON input can vary, but TeaQL entity handling follows an explicit serializer contract. Attempts to reintroduce broad reflection meet module and build-time constraints.
That is the determinism we were after: not identical outputs regardless of input, but a small, visible set of mechanisms through which input can affect execution.
The Path That Had to Become Explicit
For TeaQL, "the core path" means the operations that nearly every data request uses:
- create an entity while mapping a database row;
- assign values to that entity during hydration;
- read values generically for persistence, diffing, and auditing;
- serialize and deserialize TeaQL entities;
- create relation-reference entities.
Historically, several of these operations used general-purpose bean helpers:
BeanUtil.setProperty(entity, propertyName, value);
ReflectUtil.newInstance(entityType);
ReflectUtil.invoke(entity, updateMethod, value);
That is convenient on a regular JVM. The JVM can inspect a class, discover a method, make a member accessible, and invoke it at runtime.
A native-image build makes this uncertainty impossible to ignore because it works from a closed-world view. Code that is only discovered reflectively may need explicit reachability metadata. The more a framework relies on open-ended reflection, the harder it is to prove that the resulting image contains every constructor, field, and method that production will need.
But the same improvement helps on a regular JVM. Debuggers, static analysis, code review, compiler errors, dependency inspection, and coding agents can all reason about TeaQL's normal entity path when it is visible as ordinary Java calls. Native Image is therefore one consumer of the deterministic harness, not the sole reason it exists.
Step 1: Make Reflection a Build-Time Violation
The first major cut landed on June 13 in
97eaa06.
It split the runtime into clearer modules, introduced a zero-reflection
direction, and added a forbiddenapis signature file for APIs such as:
java.lang.reflect.Method
java.lang.reflect.Field
java.lang.reflect.Constructor
java.lang.Class#getDeclaredMethod(**)
java.lang.Class#getDeclaredField(**)
This was an architectural declaration and an enforcement mechanism, but it was
not yet the end of the migration. The same revision still had reflective paths
such as BeanUtil.setProperty() in Entity and ReflectUtil.newInstance() in
SQL row mapping.
That detail matters. Removing reflection was not one atomic rewrite. First we made the desired boundary explicit; then we replaced each path behind it.
Step 2: Generate a Uniform Property Access Channel
Database hydrators and serializers know a property by name, but business code
should mutate an entity through typed, change-tracked methods such as
updateStatus().
We needed both behaviors without rediscovering setters through reflection. The solution was a generated internal access channel:
@Override
public void __internalSet(String property, Object value) {
switch (property) {
case "name":
this.name = (String) value;
break;
case "status":
this.status = (TaskStatus) value;
break;
default:
super.__internalSet(property, value);
}
}
@Override
public Object __internalGet(String property) {
return switch (property) {
case "name" -> this.name;
case "status" -> this.status;
default -> super.__internalGet(property);
};
}
The property name is still dynamic, but dispatch is not reflective. Every
branch is generated source code and becomes a normal field access in bytecode.
There is no Method.invoke(), Field.setAccessible(), or MethodHandle
lookup.
The base class handles framework properties such as id and version; each
generated subclass handles its own fields and delegates unknown names to its
parent.
The design was documented in the internalSet/internalGet RFC and implemented
across the runtime before being given its current deliberately awkward name in
beece53:
__internalSet and __internalGet.
The double underscore is intentional. These methods must be public because
hydrators and serializers live in other modules, but they are not application
APIs. Business code should naturally find and use updateXxx() instead.
The resulting generic API is simple:
default <T> T getProperty(String propertyName) {
return (T) ((BaseEntity) this).__internalGet(propertyName);
}
default void setProperty(String propertyName, Object value) {
((BaseEntity) this).__internalSet(propertyName, value);
}
This replaced reflective bean mutation for TeaQL entities while preserving the framework's ability to hydrate an object without marking every loaded value as a business change.
Step 3: Replace Reflective Constructors with Suppliers
Property access was only half the problem. SQL mapping also created entity instances reflectively.
In
747c704,
we added an explicit factory to entity metadata:
EntityDescriptor descriptor = new EntityDescriptor();
descriptor.setTargetType(Task.class);
descriptor.setEntitySupplier(Task::new);
Entity creation then became:
public Entity createEntity() {
if (entitySupplier == null) {
throw new IllegalStateException(
"No entity supplier registered for " + getType());
}
return entitySupplier.get();
}
SQL row mapping, relation references, and runtime graph operations now resolve
the descriptor and call createEntity(). A missing registration fails early
and explicitly instead of falling back to a reflective constructor.
This trade is especially well suited to a code-generated framework: the
generator already knows the entity type, so it can emit Task::new at the same
time it emits the rest of the metadata. Knowledge that previously had to be
rediscovered at runtime is moved into the generated harness.
Step 4: Make SQL Row Mapping Ordinary Java
Once suppliers and internal property dispatch existed, the main row-mapping loop no longer needed reflective construction or bean setters:
T entity = descriptor.createEntity();
for (PropertyDescriptor property : allProperties) {
Object value = row.get(property.getName());
if (value != null) {
entity.setProperty(property.getName(),
Convert.convert(property.getType().javaType(), value));
}
}
Relation references use the same approach: resolve the related entity's
descriptor, create it through its supplier, and assign the referenced ID using
__internalSet.
The same June 24 revision also replaced reflective array expansion in portable SQL parameter handling with explicit handling for object arrays and primitive array types. It was a small path compared with entity hydration, but removing small reflective conveniences is part of making runtime reachability predictable.
Step 5: Own TeaQL Entity JSON Explicitly
Jackson can serialize and mutate ordinary beans through introspection. That is useful for application DTOs, but TeaQL entities already have metadata and a defined internal access contract.
We therefore made teaql-jackson register explicit handlers:
addSerializer(BaseEntity.class, new BaseEntityJsonSerializer());
addDeserializer(BaseEntity.class, new BaseEntityJsonDeserializer());
The deserializer routes framework-owned fields such as id and version
through __internalSet; additional values use TeaQL's additional-information
path. TeaQL entity JSON no longer depends on Jackson discovering raw setters.
This does not disable Jackson introspection for the entire application. Application DTOs and arbitrary objects may still use Jackson's normal behavior and may need their own native-image reachability metadata. The boundary is TeaQL entities, not every object an application may place in JSON.
Step 6: Move Reflection Out Instead of Pretending It Never Helps
General reflection is still useful for tooling, migration code, compatibility layers, and applications that choose to use it.
On June 24,
3acaebe
split the old mixed utility module by responsibility:
teaql-utils
framework-neutral utilities
teaql-utils-reflection
ReflectUtil and BeanUtil
teaql-utils-json
general Jackson-backed conversion helpers
teaql-utils-spring
Spring and classpath-scanning helpers
The core baseline—teaql-core, teaql-runtime, teaql-sql-portable, and
teaql-jackson—does not depend on teaql-utils-reflection. Applications can
still add that module explicitly when reflection is the right engineering
choice.
There is a deliberate compromise here. Low-level helpers in teaql-utils
still expose Java type metadata, array reflection, class loading, and a few
generic constructor conveniences. teaql-utils-json can also instantiate
general-purpose types reflectively. Native-image readiness therefore depends
on the application's reachable dependency paths, not on claiming that a grep
of the entire repository returns zero matches.
The important boundary is that normal TeaQL entity construction, hydration, generic access, SQL mapping, relation assembly, and entity JSON do not require those fallback utilities. Adding a reflective utility is an explicit dependency choice rather than an invisible property of the default runtime.
Step 7: Clean Up the Application Mutation API
Reflection removal also exposed an API-design issue: generated raw setters made it too easy for business code to bypass TeaQL's change tracking.
ba7919e
replaced remaining test-entity setter usage with the fluent updateXxx()
pattern. The two channels now have different jobs:
// Business mutation: typed and change-tracked
task.updateStatus(newStatus);
// Framework hydration: raw assignment without change tracking
task.__internalSet("status", newStatus);
This separation is not only about Native Image. It makes domain mutations more auditable and prevents hydration mechanics from leaking into application code.
The internal read path continued to evolve after the reflection work. In July,
6a18240
made it read the latest value from EntityRoot before falling back to the
entity field. The explicit access contract survived even as TeaQL's change
tracking architecture changed underneath it.
Guardrails, Not Just Conventions
The root Maven build runs forbiddenapis with TeaQL's forbidden signature
list. This turns accidental use of major reflection APIs in normal framework
code into a build failure.
Utility packages are excluded because they contain the deliberate escape hatches described above. That exclusion is another engineering compromise: the rule protects the core boundary without preventing optional utilities from doing their stated job.
This is also where reflection removal becomes harness engineering rather than a coding-style preference. A sentence in a contributor guide asks people to remember. A generated access path gives them the correct mechanism. A module boundary limits what application code can reach. A forbidden signature turns an architectural regression into a build error. An explicit supplier turns missing construction metadata into an immediate, local failure.
The constraint is carried by the environment in which code is written and executed—not only by the person or agent writing it.
The repository also documents a source-level check for the four core modules:
rg -n "ReflectUtil|BeanUtil|java\\.lang\\.reflect|setAccessible\\(|Class\\.forName\\(" \
teaql-core teaql-runtime teaql-sql-portable teaql-jackson
At the current baseline, the main sources in those modules have no direct matches for those reflection-heavy paths.
This is preparation for Native Image, not a claim that every TeaQL application will compile to a native executable without configuration. Database drivers, framework integrations, DTO serialization, proxies, and optional utilities all belong to the final application's reachability surface.
More importantly, Native Image is not the definition of success. Even when an application runs only on a regular JVM, reducing hidden discovery makes its runtime contract easier to inspect, reproduce, test, and operate.
What We Learned
Removing reflection from a framework core is less about banning an API and more about replacing the reasons the API was used—and then embedding those replacements in the execution harness:
- Generate switch dispatch when generic property names are unavoidable.
- Register constructor suppliers when metadata already knows the concrete type.
- Own entity serialization instead of delegating it to open-ended bean introspection.
- Isolate compatibility tools rather than forcing the whole ecosystem to give them up.
- Add build-time rules early, then use failures and code search to finish the migration path by path.
TeaQL Java still offers reflection where it is useful. It simply no longer requires reflection to do its most important job: turn explicit metadata, queries, database rows, and business mutations into explicit Java execution.
That is the larger outcome of the work. The core runtime has fewer hidden degrees of freedom, and more of its contract is carried by generated artifacts and executable constraints. Native Image readiness follows from that design; runtime determinism and a stronger engineering harness are the reason for it.
