Skip to main content
spring internals

Native Image Proxy Generation and RuntimeHints

7 min read Chapter 78 of 78

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:

  1. Controllers. Add @RegisterReflectionForBinding for all request and response DTOs not covered by auto-configuration.

  2. Services with reflection. Implement BeanRegistrationAotProcessor or create a RuntimeHintsRegistrar for any service that uses getDeclaredFields(), getDeclaredMethods(), or Class.forName().

  3. Manual proxies. Register every Proxy.newProxyInstance() call’s interface combination via ProxyHints.

  4. Resource loading. Register every getResourceAsStream() pattern via ResourceHints.

  5. 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.

  6. Test. Write RuntimeHintsPredicates tests for every registrar. Run @SpringBootTest with spring.aot.enabled=true on 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.