Native Image Proxy Generation and RuntimeHints
Native Image Proxy Generation and RuntimeHints
CH8 described two proxy mechanisms: JDK dynamic proxies created via java.lang.reflect.Proxy, and CGLIB proxies created via Enhancer. Both generate classes at runtime. Both are impossible in a native image.
This section covers what replaces them and how to declare the runtime access your application needs.
Build-Time Proxy Generation
In JVM mode, when Spring needs a CGLIB proxy for a @Transactional service (CH10), it calls Enhancer.create() during refresh(). The enhancer generates bytecode for a subclass, loads it through a custom classloader, and returns an instance.
In AOT mode, this proxy class is generated as Java source code during spring-boot:aot-generate. The source is compiled by javac. The resulting .class file is included in the native image like any other class.
The generated proxy for TenantService looks approximately like:
public class TenantService$$SpringCGLIB$$0 extends TenantService {
private static final Method CGLIB$findById$0$Method;
private MethodInterceptor[] CGLIB$CALLBACKS;
static {
CGLIB$findById$0$Method = TenantService.class
.getDeclaredMethod("findById", String.class);
}
@Override
public Tenant findById(String tenantId) {
MethodInterceptor interceptor = this.CGLIB$CALLBACKS[0];
if (interceptor != null) {
return (Tenant) interceptor.intercept(
this, CGLIB$findById$0$Method,
new Object[] { tenantId }, null);
}
return super.findById(tenantId);
}
}
This is the same proxy structure as CH8’s runtime-generated proxy. The difference: it is a .java file that the AOT engine wrote, compiled by the standard Java compiler, and linked into the native image. No Enhancer. No ASM. No bytecode generation at runtime.
The proxy works because its superclass (TenantService) is a concrete class that the compiler can resolve. The @Override methods are normal Java method overrides. There is nothing dynamic about it.
JDK Dynamic Proxies in Native
JDK dynamic proxies use java.lang.reflect.Proxy.newProxyInstance() to create a class that implements a set of interfaces. GraalVM supports this, but every interface combination must be declared at build time.
Spring AOT automatically handles proxies it creates. For each @Transactional interface, each @Async interface, each Spring Data repository, AOT registers the required proxy interface combination.
For proxies created by your code or third-party libraries, you must register them yourself.
The SaaS backend has a NotificationGateway interface proxied for tenant-aware routing:
public interface NotificationGateway {
void sendWelcome(String tenantId, String userId);
void sendInvoice(String tenantId, Invoice invoice);
}
Without a proxy hint, Proxy.newProxyInstance() fails at runtime with:
com.oracle.svm.core.jdk.UnsupportedFeatureError:
Proxy class defined by interfaces
[com.example.notification.NotificationGateway]
not found. Generating proxy classes at runtime
is not supported.
The RuntimeHints API
RuntimeHints is the central registry for everything the native image needs to know about runtime access. It has four categories:
ReflectionHints
Declare classes, constructors, methods, and fields that will be accessed via reflection.
RuntimeHints hints = new RuntimeHints();
// Register entire class for reflection
hints.reflection().registerType(TenantConfig.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
MemberCategory.INVOKE_PUBLIC_METHODS,
MemberCategory.DECLARED_FIELDS);
// Register specific method
hints.reflection().registerMethod(
TenantService.class.getMethod("findById", String.class),
ExecutableMode.INVOKE);
MemberCategory controls granularity. DECLARED_FIELDS includes all fields for reflection. INVOKE_PUBLIC_CONSTRUCTORS allows reflective constructor invocation. Use the narrowest category that satisfies your needs; broader categories increase image size.
ProxyHints
Declare JDK dynamic proxy interface combinations.
hints.proxies().registerJdkProxy(
NotificationGateway.class);
// Multi-interface proxy
hints.proxies().registerJdkProxy(
NotificationGateway.class,
TenantAware.class);
Each registerJdkProxy call declares one proxy class. The interfaces must be listed in the same order that Proxy.newProxyInstance() receives them.
ResourceHints
Declare classpath resources that must be included in the native image.
hints.resources().registerPattern("templates/*.html");
hints.resources().registerPattern("i18n/messages_*.properties");
Without this, ClassLoader.getResourceAsStream("templates/invoice.html") returns null in native image.
SerializationHints
Declare classes that participate in Java serialization. Rarely needed in modern Spring applications, but required if you use ObjectInputStream or Serializable session attributes.
hints.serialization().registerType(TenantSession.class);
RuntimeHintsRegistrar
The interface for contributing hints is RuntimeHintsRegistrar. Implement it, then reference it from @ImportRuntimeHints.
public class SaasRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints,
ClassLoader classLoader) {
// Jackson needs reflection for DTOs
hints.reflection().registerType(TenantResponse.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
MemberCategory.INVOKE_PUBLIC_METHODS,
MemberCategory.DECLARED_FIELDS);
hints.reflection().registerType(OrderResponse.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
MemberCategory.INVOKE_PUBLIC_METHODS,
MemberCategory.DECLARED_FIELDS);
// Manual JDK proxy
hints.proxies().registerJdkProxy(
NotificationGateway.class);
// Template resources
hints.resources().registerPattern("templates/*.html");
}
}
Apply it to a configuration class:
@Configuration
@ImportRuntimeHints(SaasRuntimeHints.class)
public class NativeConfig {
// no beans needed, just the hint registration
}
The registrar runs at build time during AOT processing. Its output is written to reflect-config.json, proxy-config.json, and resource-config.json in the native image configuration directory.
@RegisterReflectionForBinding
For the common case of DTOs that Jackson (or another serialization library) must access via reflection, Spring provides @RegisterReflectionForBinding. It registers the class for reflection with all member categories needed for data binding.
@RestController
@RegisterReflectionForBinding({
TenantResponse.class,
OrderResponse.class,
InvoiceResponse.class,
CreateTenantRequest.class,
UpdateOrderRequest.class
})
public class TenantController {
@GetMapping("/api/tenants/{id}")
public TenantResponse getTenant(@PathVariable String id) {
return tenantService.findById(id);
}
@PostMapping("/api/tenants")
public TenantResponse createTenant(
@RequestBody CreateTenantRequest request) {
return tenantService.create(request);
}
}
Without this annotation, Jackson fails at runtime:
// BROKEN: no reflection hints for DTO
@RestController
public class TenantController {
@GetMapping("/api/tenants/{id}")
public TenantResponse getTenant(@PathVariable String id) {
// Works on JVM. Fails in native image.
// Jackson cannot access TenantResponse fields.
// Error: No serializer found for class TenantResponse
return tenantService.findById(id);
}
}
The error message is misleading. It says “no serializer found,” but the root cause is that Jackson cannot see the class fields because reflection metadata was not included in the native image.
// CORRECT: explicit reflection registration
@RestController
@RegisterReflectionForBinding(TenantResponse.class)
public class TenantController {
@GetMapping("/api/tenants/{id}")
public TenantResponse getTenant(@PathVariable String id) {
return tenantService.findById(id);
}
}
Spring Boot auto-configuration handles many common cases: @ConfigurationProperties classes, Spring Data entities, Spring MVC request/response types used in handler method signatures. But DTOs that appear only in your custom code, or classes used by third-party libraries, require explicit registration.
Testing with RuntimeHintsPredicates
Discovering missing hints at native image build time is expensive. The build takes minutes. Spring provides RuntimeHintsPredicates for unit testing hint registrations.
@Test
void tenantResponseHasReflectionHints() {
RuntimeHints hints = new RuntimeHints();
new SaasRuntimeHints().registerHints(hints, getClass().getClassLoader());
assertThat(RuntimeHintsPredicates.reflection()
.onType(TenantResponse.class)
.withMemberCategories(
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
MemberCategory.DECLARED_FIELDS))
.accepts(hints);
}
@Test
void notificationGatewayHasProxyHints() {
RuntimeHints hints = new RuntimeHints();
new SaasRuntimeHints().registerHints(hints, getClass().getClassLoader());
assertThat(RuntimeHintsPredicates.proxies()
.forInterfaces(NotificationGateway.class))
.accepts(hints);
}
@Test
void templateResourcesIncluded() {
RuntimeHints hints = new RuntimeHints();
new SaasRuntimeHints().registerHints(hints, getClass().getClassLoader());
assertThat(RuntimeHintsPredicates.resource()
.forResource("templates/invoice.html"))
.accepts(hints);
}
These tests run in seconds on the JVM. They verify that your registrar declares the hints you expect. They do not verify that the hints are sufficient for the native image, but they catch the most common mistake: forgetting to register a class entirely.
Spring Boot also provides @ImportAutoConfiguration test slices that include AOT-aware auto-configurations. Running your @SpringBootTest with -Dspring.aot.enabled=true executes the AOT-generated code on the JVM, which catches a wider range of issues.
The Complete Native Checklist
For each component in the SaaS backend going native:
-
Controllers. Add
@RegisterReflectionForBindingfor all request and response DTOs not covered by auto-configuration. -
Services with reflection. Implement
BeanRegistrationAotProcessoror create aRuntimeHintsRegistrarfor any service that usesgetDeclaredFields(),getDeclaredMethods(), orClass.forName(). -
Manual proxies. Register every
Proxy.newProxyInstance()call’s interface combination viaProxyHints. -
Resource loading. Register every
getResourceAsStream()pattern viaResourceHints. -
Third-party libraries. Check if the library provides its own
RuntimeHintsRegistrar. Many Spring ecosystem libraries (Spring Data, Spring Security, Spring Cloud) do. Libraries outside the ecosystem may not. -
Test. Write
RuntimeHintsPredicatestests for every registrar. Run@SpringBootTestwithspring.aot.enabled=trueon the JVM before attempting a native build.
The native image is not a free optimization. It is a different deployment model with different constraints. Every reflective operation, every dynamic proxy, every classpath resource must be declared. The reward is 0.1-second startup, 60MB memory, and instant scaling for the multi-tenant SaaS backend.