Spring AOT Processing: Build-Time Bean Definition Generation
Spring AOT Processing: Build-Time Bean Definition Generation
CH26 established that AOT processing runs a full ApplicationContext.refresh() at build time and serializes the result into Java source code. This section examines the code generation in detail: what gets generated, by what processor, and how it replaces the runtime mechanisms from CH2 and CH3.
The AOT Processing Pipeline
When mvn spring-boot:aot-generate executes, the Spring AOT engine runs inside the build process itself. The entry point is SpringApplicationAotProcessor. It creates an ApplicationContext, refreshes it, and then passes it to the code generation pipeline.
// Simplified: what the AOT plugin does
GenericApplicationContext context = new GenericApplicationContext();
// Register all sources (main class, imports, component scanning)
context.refresh();
// Hand the fully-initialized context to AOT
ApplicationContextAotGenerator generator =
new ApplicationContextAotGenerator();
GeneratedClasses generatedClasses =
generator.processAheadOfTime(context);
The call to refresh() is real. Every BeanFactoryPostProcessor runs. Every @Conditional evaluates. Every bean definition is registered. The context reaches the same state it would on a JVM at startup. The difference is what happens next.
Instead of proceeding to bean instantiation and dependency injection, the AOT engine iterates over every BeanDefinition in the factory and generates code for each one.
BeanRegistrationAotProcessor
The core interface driving code generation is BeanRegistrationAotProcessor. Spring provides a default implementation that handles standard beans, but any component can implement this interface to participate in AOT processing.
public interface BeanRegistrationAotProcessor {
BeanRegistrationAotContribution processAheadOfTime(
RegisteredBean registeredBean);
}
For each bean in the context, the engine calls every registered BeanRegistrationAotProcessor. If a processor returns a non-null BeanRegistrationAotContribution, that contribution is invoked during code generation.
The default processor, BeanDefinitionMethodGeneratorFactory, handles the common case: it generates a static factory method that creates the bean definition and an instance supplier that constructs the bean.
Generated __BeanDefinitions Classes
For every bean in the SaaS backend, AOT generates a class following the naming convention ClassName__BeanDefinitions. These classes are placed in the same package as the original bean.
Consider the TenantRepository from earlier chapters:
@Repository
public class TenantRepository {
private final JdbcTemplate jdbcTemplate;
public TenantRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public Tenant findById(String tenantId) {
return jdbcTemplate.queryForObject(
"SELECT * FROM tenants WHERE id = ?",
new TenantRowMapper(), tenantId);
}
}
AOT generates:
public class TenantRepository__BeanDefinitions {
/**
* Get the bean definition for 'tenantRepository'.
*/
public static BeanDefinition getTenantRepositoryBeanDefinition() {
RootBeanDefinition definition =
new RootBeanDefinition(TenantRepository.class);
definition.setInstanceSupplier(
getTenantRepositoryInstanceSupplier());
return definition;
}
/**
* Get the instance supplier for 'tenantRepository'.
*/
private static BeanInstanceSupplier<TenantRepository>
getTenantRepositoryInstanceSupplier() {
return BeanInstanceSupplier
.<TenantRepository>forConstructor(JdbcTemplate.class)
.withGenerator((registeredBean, args) ->
new TenantRepository(args.get(0)));
}
}
Key observations:
-
No reflection. The constructor call is
new TenantRepository(args.get(0)). NoConstructor.newInstance(). NoField.set(). The generated code calls the constructor directly through a lambda. -
No scanning. The
@Repositoryannotation is irrelevant at runtime. The bean is registered because the generated code explicitly creates itsBeanDefinition. The annotation was used at build time to discover the bean. It serves no runtime purpose in native. -
No autowiring.
AutowiredAnnotationBeanPostProcessor(CH3) does not run. The generatedBeanInstanceSupplierknows the constructor parameter types. It resolvesJdbcTemplate.classfrom the bean factory and passes it directly. -
Deterministic. The generated code always produces the same bean. There is no classpath variance, no condition evaluation, no ordering ambiguity.
What Replaces Runtime Scanning
In JVM mode (CH2), ClassPathBeanDefinitionScanner walks the classpath using ASM, reads class metadata without loading classes, and registers bean definitions for every class annotated with a stereotype annotation. This is expensive: the SaaS backend scans 2000+ classes across 40+ packages.
In AOT mode, scanning happens once, at build time. The result is captured in a generated __BeanFactoryRegistrations class:
public class SaasApplication__BeanFactoryRegistrations {
public static void registerBeanDefinitions(
DefaultListableBeanFactory beanFactory) {
beanFactory.registerBeanDefinition("tenantService",
TenantService__BeanDefinitions
.getTenantServiceBeanDefinition());
beanFactory.registerBeanDefinition("tenantRepository",
TenantRepository__BeanDefinitions
.getTenantRepositoryBeanDefinition());
beanFactory.registerBeanDefinition("orderService",
OrderService__BeanDefinitions
.getOrderServiceBeanDefinition());
// ... every bean, explicitly listed
}
}
At runtime, the native image calls registerBeanDefinitions(). One method call. No scanning. No classpath traversal. No ASM parsing.
Conditions Resolved at Build Time
CH5 described how @ConditionalOnClass, @ConditionalOnBean, and @ConditionalOnProperty are evaluated during refresh(). In AOT, these conditions evaluate during the build-time refresh(). The result is final.
If DataSourceAutoConfiguration matches (because HikariCP is on the classpath), its beans appear in the generated registrations. If it does not match, those beans are absent from the generated code. There is no mechanism to re-evaluate at runtime.
This creates a trap for runtime-dependent conditions:
// BROKEN: condition depends on runtime state
@Configuration
@ConditionalOnBean(CacheManager.class)
public class CachedTenantConfig {
@Bean
public TenantCache tenantCache(CacheManager cacheManager) {
return new TenantCache(cacheManager);
}
}
If CacheManager exists at build time, CachedTenantConfig is included. If a different build profile excludes the cache dependency, it is not. The condition cannot change between build and runtime.
// CORRECT: use build profiles to create environment-specific images
// Build command: mvn -Pcaching native:compile
// This produces a native image that includes caching.
// For non-caching environments, build without the profile.
@Configuration
@Profile("caching")
public class CachedTenantConfig {
@Bean
@ConditionalOnBean(CacheManager.class)
public TenantCache tenantCache(CacheManager cacheManager) {
return new TenantCache(cacheManager);
}
}
The principle: anything that varies between environments requires separate native images. There is no single binary that adapts to its environment. This is the fundamental tradeoff of static compilation.
Custom BeanRegistrationAotProcessor
When the default code generation is insufficient, you implement BeanRegistrationAotProcessor to control what gets generated for a specific bean.
The SaaS backend has a TenantAwareInterceptor that uses reflection to read @TenantScoped annotations at runtime. The default AOT processor generates a standard bean definition, but it does not generate the reflection hints needed for the annotation inspection.
@Component
public class TenantAwareAotProcessor
implements BeanRegistrationAotProcessor {
@Override
public BeanRegistrationAotContribution processAheadOfTime(
RegisteredBean registeredBean) {
Class<?> beanClass = registeredBean.getBeanClass();
boolean hasTenantFields = Arrays.stream(
beanClass.getDeclaredFields())
.anyMatch(f -> f.isAnnotationPresent(TenantScoped.class));
if (!hasTenantFields) {
return null; // no contribution needed
}
return (context, code) -> {
RuntimeHints hints = context.getRuntimeHints();
hints.reflection().registerType(beanClass,
MemberCategory.DECLARED_FIELDS);
};
}
}
This processor runs at build time. It inspects every bean class for @TenantScoped fields. For beans that have them, it generates a reflection hint so that the native image includes the field metadata.
Generated Code Location
AOT-generated sources are written to target/spring-aot/main/sources/ (Maven) or build/generated/aotSources/ (Gradle). You can inspect them directly:
find target/spring-aot/main/sources -name "*__BeanDefinitions.java" | head -10
For the SaaS backend, this produces 100+ generated classes. Each one is readable Java code. When a native image fails to start, inspecting these classes is the first debugging step. They show exactly what the AOT engine decided about each bean.
The Debugging Workflow
When something goes wrong with AOT:
-
Check the generated code. Is the bean present in
__BeanFactoryRegistrations? If not, a condition excluded it at build time. -
Run with
-Dspring.aot.enabled=trueon the JVM. This executes the AOT-generated code on a normal JVM, where you get stack traces and debugger support. Most AOT failures reproduce here. -
Check the condition evaluation report. Add
--debugto the AOT build command. The report shows which conditions matched and which did not, exactly as described in CH5, but evaluated at build time. -
Verify the bean class is reachable. GraalVM’s points-to analysis may eliminate classes it considers unreachable. If your
BeanRegistrationAotProcessorreferences a class only through reflection, that class might not survive analysis.
The next section covers proxy pre-generation and the RuntimeHints API that controls what the native image includes.