Skip to content

Reflection — Class Mapping, Mixins, and Proxy Support

Overview and Motivation

org.tentackle.reflect is where the framework's dynamic behavior is implemented. Three of Tentackle's most distinctive features are not language features — they are reflection, done carefully and cached:

  • PDOs emulate multiple inheritance. A PersistentDomainObject is a dynamic proxy whose calls are routed to two delegates, a persistence one and a domain one. The Mixin is what makes that work.
  • Implementations are found, not wired. @MappedService says "this class implements that interface"; the ClassMapper is the runtime engine that resolves it — including up class hierarchies and across packages.
  • Immutable objects can be deserialized. An object with no no-arg constructor and final fields cannot be reconstructed the usual way. The @CanonicalConstructor family tells TRIP how to build it.

The package also holds the interception machinery. That half is documented in depth in interceptors.md — this document covers the rest and explains only how interception plugs into the proxy support described here.

A word on the design: reflection is slow if you let it be. Nearly every type in this package exists to do a reflective lookup once and cache it — ConcurrentHashMaps of resolved mappings, constructors, method indexes, and property accessors are the norm. The expensive work happens at class-load or first-use time; the steady state is a hash lookup.

Class Mapping

ClassMapper — the engine behind @MappedService

The service API has two flavors: @Service locates a singleton, @MappedService maps implementations to the interfaces they implement. The build-time tooling writes those relationships into META-INF; at runtime, ClassMapper resolves them.

A mapper is created per mapped service type and given a name used in logs and error messages:

ClassMapper mapper = ClassMapper.create("FX table-config-provider", TableConfigurationProvider.class);
Class<?> providerClass = mapper.mapLenient(rowClass);

The interface is small — map and mapLenient, each taking a Class or a name, plus a default class and listeners — but the lenient variant carries the weight:

Method Lookup
map(Class \| String) The explicit mapping only. Throws ClassNotFoundException if there is none.
mapLenient(Class \| String) Walks up the superclass chain (all the way to Object), then tries the interfaces, recursively through super-interfaces.

Lenient mapping is why a provider registered for a base class or an interface serves every subclass without further registration — one GuiProvider for an entity hierarchy, one TableCellType for PersistentDomainObject covering every PDO. Note the asymmetry, which the javadoc calls out: when mapping a class, its interfaces are ignored until the superclass chain is exhausted.

setDefaultClass(...) supplies a fallback for unmapped classes instead of an exception, and ClassMappedListeners fire the first time a class is mapped.

The base implementation AbstractClassMapper provides the lenient and package/basename lookup and caches every resolved name in a ConcurrentHashMap, so the hierarchy walk happens once per class. Resolution is logged at FINE (configurable per mapper) — the log line <mapper>: added <from> -> <to> is the first place to look when an implementation isn't found.

  • DefaultClassMapper — initialized from a Map or a Properties object (a file, XML, or built at runtime).
  • ClassToServicesMapper — maps a class to all matching services rather than the first, for cases where every implementation on the path should contribute.
  • ClassMapperFactory — the replaceable singleton behind ClassMapper.create(...).

Consumers are everywhere: PdoInvocationHandler (persistence and domain implementations), the TRIP factories, and the FX factories for GuiProviders, table configurations, cell types and context menus.

Proxies and Mixins

Mixin — emulating multiple inheritance

Java has single inheritance. A PDO needs two implementations — persistence and domain — behind one interface. Mixin<T,M> is the piece that closes the gap.

A mixin binds three things: the declaring interface, the implementation class found through a ClassMapper, and the delegate instance it creates. Given a PDO interface, the constructor maps leniently to the implementation, then identifies which of that implementation's interfaces extends the expected super-interface — that becomes the declaring class, and it is how the invocation handler later decides whether a method belongs to this mixin.

PdoInvocationHandler holds exactly two:

private final Mixin<T,PersistentObject<T>> persistenceMixin;
private final Mixin<T,DomainObject<T>> domainMixin;
...
persistenceMixin = new Mixin<>(persistenceMapper, clazz, PersistentObject.class);
domainMixin      = new Mixin<>(domainMapper,      clazz, DomainObject.class);

Each call on the proxy is routed to the delegate whose declaring interface declares the method. The result is an object that is both its persistence and its domain implementation — see pdo.md for what that buys you and modules.md for why the two implementations live in separate modules that never see each other.

Mixins cache aggressively: constructors are looked up once per (implementation class, parameter types) pair, and a static match cache remembers, per class pair, whether a class implements a given declaring interface. @TripReferencable(NEVER) marks them as never referenced on the wire — the proxy is what travels, not its mixins.

DummyDelegate is a marker letting a mixin with a real delegate class be treated as a dummy — used where a proxy needs the shape but not the behavior.

EffectiveClassProvider — what getClass() can't tell you

Call getClass() on a dynamic proxy and you get the proxy's synthetic class, which is almost never what you want. EffectiveClassProvider<T> is the interface an object implements to report its effective class — the one the application thinks in terms of.

This matters more than it sounds: validation, scripting, the FX table configuration and the security interceptors all key off the effective class, and all of them would silently misbehave against a proxy class name. Implementors include AbstractDomainObject, AbstractDomainOperation, ValidationUtilities, ScriptVariable, DefaultTableConfiguration, and the security interceptors.

Interception

The interception types — Interceptable, @Interception, Interceptor, AbstractInterceptor, InterceptableMethod, InterceptableMethodCache and InterceptableInvocationHandler — are covered by interceptors.md: the annotation model, the proceed contract, chaining and order, the built-in interceptors, and compile-time verification.

What belongs here is how they connect to the machinery above. Interception is layered onto the same dynamic proxies the mixins feed: InterceptableFactory (with DefaultInterceptableFactory) creates interceptable objects; @InterceptionService is a @MappedService for Interceptable, so implementations are located through a ClassMapper exactly like any other mapped service; InterceptionUtilities is the replaceable @Service holding the helper logic; and InterceptableMethodInvoker — one per interceptable type (PDO, operation, …) — performs the invocation and extends MethodStatistics, which is where per-method invocation counts and timings come from (see logging.md).

Canonical Construction

An immutable object — final fields, no setters, no no-arg constructor — is a problem for any deserializer: there is no way to create it empty and fill it in. Java records solve this with a canonical constructor the compiler knows about. Tentackle generalizes the idea to ordinary classes with a small annotation family, which is how TRIP reconstructs immutable values without requiring -parameters at compile time or resorting to Unsafe.

Annotation Applies to Meaning
@CanonicalConstructor constructor This is the constructor to build the object with
@CanonicalName parameter The parameter's name, since reflection can't recover it without -parameters
@CanonicalField field This field supplies a constructor argument
@CanonicalGetter method This public no-arg getter supplies a constructor argument
@CanonicalBuilder type or constructor The object is built through a builder instead

The matching of constructor parameters to members is by name: either annotate the parameters with @CanonicalName, or annotate the members with @CanonicalField / @CanonicalGetter. The explicit-name requirement is a deliberate trade — Tentackle does not want to force every application to compile with javac -parameters.

@CanonicalBuilder covers the builder pattern: the constructor takes a single builder argument; the builder has a no-arg constructor, one method per field named after the field, taking the value and returning the builder, plus a no-arg method returning the finished object. The DTO wurblet's --canonical option generates exactly this shape.

ObjectTripType reads all of it through ReflectionHelper.getCanonicalConstructor(...) / getCanonicalBuilderInfo(...) and switches to delayed construction: components are deserialized first, then the object is built in one shot. Without a canonical constructor or a no-arg constructor, a class is simply not serializable by TRIP — which is why trip.md recommends adding one as the first remedy.

Caches, Visitors, and Helpers

Method caches

  • MethodCache — a thread-safe cache for method lookups that involve a best-match search, which is expensive to repeat. ReflectiveVisitor is the motivating case.
  • IndexedMethodCache — one per class, mapping methods to small integer indexes and back. This is what lets TRIP name a method on the wire with an int rather than a signature string: DefaultRemoteDelegate and DefaultRegistry assign an index on first use and both sides then speak in indexes.

ReflectiveVisitor

ReflectiveVisitor implements the visitor pattern without the accept-method boilerplate: the visit method for a concrete class is found by reflection, choosing the most specific match, so one method can serve a whole class hierarchy and a more specific one overrides it for a subclass. It backs PersistenceVisitor, which is how applications add behavior across entity types without touching the entities — see database.md.

PropertyMap

PropertyMap<T> exposes a bean's properties as a Map<String,Object>, with PropertyMapping<T> describing each one — get/set or is/set pairs, or a read-only method. AbstractBuilder in tentackle-fx uses it to configure components generically by property name.

ReflectionHelper

ReflectionHelper is the static toolbox the rest of the framework reaches for. Beyond the canonical-construction lookups already mentioned:

Area Methods
Names getClassBaseName, getPackageName, getPackagePathName, getOutermostClassName, getSerializableClassName, methodToString, makeDeclareString
Loading loadClass
Primitives primitiveToWrapperClass, wrapperToPrimitiveClass
Type tests isAssignableFrom, isAnnotationPresent (both with all/any semantics), isNonStaticInnerClass, isImmutableCollection
Generics extractGenericInnerTypeClass
Members getAllFields, getAllMethods (walking the hierarchy)
Bean conventions getGetterName, getFieldName, getGetter, getSetter, isGetter, isSetter

The small details matter here: it knows about synthetic this$0 parameters of inner classes and about java.util.ImmutableCollections, both of which trip up naive reflection over serialized graphs.

Package Layout

Group Types
Class mapping ClassMapper, AbstractClassMapper, DefaultClassMapper, ClassMapperFactory, DefaultClassMapperFactory, ClassToServicesMapper, ClassMappedListener
Proxies & mixins Mixin, DummyDelegate, EffectiveClassProvider
Interception Interceptable, Interception, Interceptor, AbstractInterceptor, InterceptableMethod, InterceptableMethodCache, InterceptableMethodInvoker, InterceptableInvocationHandler, InterceptableFactory, DefaultInterceptableFactory, InterceptionService, InterceptionUtilitiesinterceptors.md
Canonical construction CanonicalConstructor, CanonicalName, CanonicalField, CanonicalGetter, CanonicalBuilder
Caches MethodCache, IndexedMethodCache
Helpers ReflectionHelper, ReflectiveVisitor, PropertyMap, PropertyMapping
  • Tentackle Core — the runtime foundation this package belongs to.
  • Interceptors — the interception model in depth.
  • PDO — the dynamic proxy the mixins build.
  • Service and Configuration API@Service and @MappedService, which ClassMapper resolves.
  • TRIP — canonical construction and indexed method calls in practice.
  • Wurblets — the DTO wurblet's --canonical option.
  • Miscellaneous — the sibling utility package.