DEV Community

Silver_dev
Silver_dev

Posted on

Spring Boot Under the Hood. Part 3: Assembling the Container — How Boot Builds Its ApplicationContext

3.1. What Is an ApplicationContext and Why There Are Several

ApplicationContext is an extended BeanFactory that adds:

  • ApplicationEvent support (publish/subscribe)
  • MessageSource (i18n)
  • ResourceLoader (file access)
  • Automatic registration of BeanPostProcessors / BeanFactoryPostProcessors
  • Context hierarchy (parent/child)

In Spring Boot there are three basic context types — one per WebApplicationType:

WebApplicationType Context class Module (Boot 4)
SERVLET AnnotationConfigServletWebServerApplicationContext spring-boot-webmvc
REACTIVE AnnotationConfigReactiveWebServerApplicationContext spring-boot-webflux
NONE AnnotationConfigApplicationContext spring-boot (core)

The key difference between AnnotationConfigServletWebServerApplicationContext and a plain AnnotationConfigApplicationContext is that the former knows how to create and manage an embedded web server (Tomcat/Jetty). It extends ServletWebServerApplicationContext, which implements WebServerApplicationContext and contains methods like createWebServer() and getWebServer().

3.2. createApplicationContext() — Choosing the Context

This step in SpringApplication.run() looks like this:

protected ConfigurableApplicationContext createApplicationContext() {
    return this.applicationContextFactory.create(this.webApplicationType);
}
Enter fullscreen mode Exit fullscreen mode

ApplicationContextFactory is a functional interface (since Spring Boot 2.4) responsible for creating the context based on the application type:

@FunctionalInterface
public interface ApplicationContextFactory {
    ConfigurableApplicationContext create(WebApplicationType webApplicationType);

    // ... factory methods of(...)
}
Enter fullscreen mode Exit fullscreen mode

The default implementation (ApplicationContextFactory.DEFAULT) looks roughly like this:

ApplicationContextFactory DEFAULT = (webApplicationType) -> {
    try {
        switch (webApplicationType) {
            case SERVLET:
                return new AnnotationConfigServletWebServerApplicationContext();
            case REACTIVE:
                return new AnnotationConfigReactiveWebServerApplicationContext();
            default:
                return new AnnotationConfigApplicationContext();
        }
    } catch (Exception ex) {
        throw new IllegalStateException(
            "Unable create a default ApplicationContext instance, "
            + "you may need a custom ApplicationContextFactory", ex);
    }
};
Enter fullscreen mode Exit fullscreen mode

How to customize it

// Approach 1: via SpringApplication
SpringApplication app = new SpringApplication(MyApp.class);
app.setApplicationContextFactory(ctx -> new MyCustomContext());

// Approach 2: via SpringApplicationBuilder
new SpringApplicationBuilder(MyApp.class)
    .contextFactory(ApplicationContextFactory.of(MyCustomContext::new))
    .run(args);
Enter fullscreen mode Exit fullscreen mode

ApplicationContextFactory.of(Supplier<ConfigurableApplicationContext>) creates a factory that simply calls the supplied Supplier.

Gotcha. The context you return must be "raw" — Spring Boot itself will call setEnvironment(), prepareContext(), and refresh(). If you return an already refreshed context, you'll end up with double initialization.

3.3. prepareContext() — Preparing the Context Before refresh

This is the busiest step. Here is its full skeleton (Spring Boot 3.x):

private void prepareContext(DefaultBootstrapContext bootstrapContext,
        ConfigurableApplicationContext context,
        ConfigurableEnvironment environment,
        SpringApplicationRunListeners listeners,
        ApplicationArguments applicationArguments,
        Banner printedBanner) {

    // 1. Set the Environment
    context.setEnvironment(environment);

    // 2. Post-process the context (bean name generator, resource loader, ConversionService)
    postProcessApplicationContext(context);

    // 3. Apply the ApplicationContextInitializers
    applyInitializers(context);

    // 4. Publish ApplicationContextInitializedEvent
    listeners.contextPrepared(context);

    // 5. Log startup info and profiles
    if (this.logStartupInfo) {
        logStartupInfo(context.getParent() == null);
        logStartupProfileInfo(context);
    }

    // 6. Register Boot-specific singleton beans
    ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
    beanFactory.registerSingleton("springApplicationArguments", applicationArguments);
    if (printedBanner != null) {
        beanFactory.registerSingleton("springBootBanner", printedBanner);
    }

    // 7. Configure allowBeanDefinitionOverriding
    if (beanFactory instanceof DefaultListableBeanFactory dlbf) {
        dlbf.setAllowBeanDefinitionOverriding(this.allowBeanDefinitionOverriding);
    }

    // 8. Lazy initialization
    if (this.lazyInitialization) {
        context.addBeanFactoryPostProcessor(
            new LazyInitializationBeanFactoryPostProcessor());
    }

    // 9. Load all sources (the main @Configuration class, etc.)
    Set<Object> sources = getAllSources();
    load(context, sources.toArray(new Object[0]));

    // 10. Publish ApplicationPreparedEvent
    listeners.contextLoaded(context);
}
Enter fullscreen mode Exit fullscreen mode

We briefly touched on this step in Part 1 — now let's break down the key steps in depth.

Step 1: context.setEnvironment(environment)

The Environment prepared in Part 2 is handed over to the context. From this moment on, EnvironmentAware beans and @Value annotations can read properties.

Step 2: postProcessApplicationContext()

protected void postProcessApplicationContext(ConfigurableApplicationContext context) {
    if (this.beanNameGenerator != null) {
        context.getBeanFactory().registerSingleton(
            AnnotationConfigUtils.CONFIGURATION_BEAN_NAME_GENERATOR,
            this.beanNameGenerator);
    }
    if (this.resourceLoader != null) {
        if (context instanceof GenericApplicationContext gac) {
            gac.setResourceLoader(this.resourceLoader);
        }
        context.getBeanFactory().registerResolvableDependency(
            ResourceLoader.class, this.resourceLoader);
    }
    if (this.addConversionService) {
        context.getBeanFactory().setConversionService(
            ApplicationConversionService.getSharedInstance());
    }
}
Enter fullscreen mode Exit fullscreen mode

Three things:

  • BeanNameGenerator — the bean naming strategy (default: AnnotationBeanNameGenerator).
  • ResourceLoader — where to read resources from (default: DefaultResourceLoader).
  • ConversionService — ApplicationConversionService for type conversion (used by @ConfigurationProperties and @Value).

Step 3: applyInitializers() — Extension Point #1

protected void applyInitializers(ConfigurableApplicationContext context) {
    for (ApplicationContextInitializer initializer : getInitializers()) {
        Class<?> requiredType = GenericTypeResolver.resolveTypeArgument(
            initializer.getClass(), ApplicationContextInitializer.class);
        Assert.isInstanceOf(requiredType, context,
            "Unable to call initializer.");
        initializer.initialize(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

ApplicationContextInitializer is an SPI interface from Spring Framework:

@FunctionalInterface
public interface ApplicationContextInitializer<C extends ConfigurableApplicationContext> {
    void initialize(C applicationContext);
}
Enter fullscreen mode Exit fullscreen mode

It is invoked before refresh(), while the context is still empty. Typical scenarios:

  • Programmatically activating profiles: context.getEnvironment().addActiveProfile("dev")
  • Registering a PropertySource from a non-standard source
  • Setting a parent context

Registration via spring.factories:

# META-INF/spring.factories
org.springframework.context.ApplicationContextInitializer=\
com.example.MyInitializer
Enter fullscreen mode Exit fullscreen mode

Programmatic registration:

SpringApplication app = new SpringApplication(MyApp.class);
app.addInitializers(ctx -> ctx.getEnvironment().addActiveProfile("metrics"));
Enter fullscreen mode Exit fullscreen mode

Note: the built-in Spring Boot ApplicationContextInitializers (e.g., ConfigurationWarningsApplicationContextInitializer, ContextIdApplicationContextInitializer) are loaded in the SpringApplication constructor, not here. applyInitializers() invokes both them and the user-added ones.

Step 4: listeners.contextPrepared()

ApplicationContextInitializedEvent is published. At this point the context has been created, the Environment is set, and the initializers have been applied — but no beans have been loaded yet. This is the last chance to tamper with the context without risking breaking the dependency graph.

Step 5: logging the profiles

protected void logStartupProfileInfo(ConfigurableApplicationContext context) {
    Log log = getApplicationLog();
    if (log.isInfoEnabled()) {
        String[] activeProfiles = context.getEnvironment().getActiveProfiles();
        // ... prints: "The following 1 profile is active: \"dev\""
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the exact line you see in the logs at startup:

The following 1 profile is active: "dev"
Enter fullscreen mode Exit fullscreen mode

Step 6: registering singleton beans

beanFactory.registerSingleton("springApplicationArguments", applicationArguments);
beanFactory.registerSingleton("springBootBanner", printedBanner);
Enter fullscreen mode Exit fullscreen mode

These objects are not @Components — they are registered manually. Internally, registerSingleton() calls addSingleton() in DefaultSingletonBeanRegistry, i.e., it puts the object straight into the singleton cache, bypassing the regular bean lifecycle.

This means:

  • They cannot be proxied by a BeanPostProcessor
  • They don't go through @PostConstruct / InitializingBean
  • But they can be injected via @Autowired ApplicationArguments

Step 7: allowBeanDefinitionOverriding

if (beanFactory instanceof DefaultListableBeanFactory dlbf) {
    dlbf.setAllowBeanDefinitionOverriding(this.allowBeanDefinitionOverriding);
}
Enter fullscreen mode Exit fullscreen mode

The default since Boot 2.1 is false. If two @Bean methods define a bean with the same name → BeanDefinitionOverrideException. You can allow it via spring.main.allow-bean-definition-overriding=true.

Step 8: getAllSources() + load() — entering the world of BeanDefinitions

protected Set<Object> getAllSources() {
    Set<Object> allSources = new LinkedHashSet<>();
    if (!CollectionUtils.isEmpty(this.primarySources)) {
        allSources.addAll(this.primarySources);
    }
    if (!CollectionUtils.isEmpty(this.sources)) {
        allSources.addAll(this.sources);
    }
    return Collections.unmodifiableSet(allSources);
}
Enter fullscreen mode Exit fullscreen mode

primarySources is your @SpringBootApplication class. Additional sources can be added via SpringApplicationBuilder.sources(...).

Then — load():

protected void load(ApplicationContext context, Object[] sources) {
    BeanDefinitionLoader loader = createBeanDefinitionLoader(
        getBeanDefinitionRegistry(context), sources);
    // ...
    loader.load();
}
Enter fullscreen mode Exit fullscreen mode

Step 9: listeners.contextLoaded()

ApplicationPreparedEvent is published. The context is ready for refresh().

3.4. BeanDefinitionLoader — the Facade for Bean Loading

This is the key class that turns your @Configuration classes into BeanDefinitions. It's not part of Spring Framework — it belongs to Spring Boot.

What it can do

BeanDefinitionLoader is a facade over three readers:

Source Reader
Class<?> (your @Configuration) AnnotatedBeanDefinitionReader
Resource (an XML file) XmlBeanDefinitionReader
Package (a base package) ClassPathBeanDefinitionScanner
CharSequence (a string) Tries Class → Resource → Package

How it's structured

class BeanDefinitionLoader {
    private final BeanDefinitionRegistry registry;
    private final AnnotatedBeanDefinitionReader annotatedReader;
    private final XmlBeanDefinitionReader xmlReader;
    private final ClassPathBeanDefinitionScanner scanner;

    BeanDefinitionLoader(BeanDefinitionRegistry registry, Object... sources) {
        this.registry = registry;
        this.annotatedReader = new AnnotatedBeanDefinitionReader(registry);
        this.xmlReader = new XmlBeanDefinitionReader(registry);
        this.scanner = new ClassPathBeanDefinitionScanner(registry, false);
        // ... setEnvironment, setResourceLoader
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that the ClassPathBeanDefinitionScanner is created with the flag false — no default include filters. This means it does not scan for @Components automatically until you call scan(). The actual scanning happens later, when ConfigurationClassPostProcessor processes the @ComponentScan from your @SpringBootApplication.

How load() works

int load() {
    int count = 0;
    for (Object source : this.sources) {
        count += load(source);
    }
    return count;
}

private int load(Object source) {
    if (source instanceof Class<?> clazz)    return load(clazz);
    if (source instanceof Resource res)     return load(res);
    if (source instanceof Package pkg)      return load(pkg);
    if (source instanceof CharSequence cs)  return load(cs);
    throw new IllegalArgumentException("Invalid source type " + source.getClass());
}
Enter fullscreen mode Exit fullscreen mode

For your @SpringBootApplication class, load(Class<?>) gets called:

private int load(Class<?> source) {
    if (isGroovyPresent() && GroovyBeanDefinitionSource.class.isAssignableFrom(source)) {
        // ... Groovy
    }
    if (isComponent(source)) {
        this.annotatedReader.register(source);
        return 1;
    }
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

In other words, if the class is marked @Component (and @SpringBootApplication includes @Component via meta-annotations), it is registered through AnnotatedBeanDefinitionReader. An AnnotatedGenericBeanDefinition is created, which stores the annotation metadata.

Important: at this point only the BeanDefinition for the @SpringBootApplication class itself is created. All the other beans (services, controllers, @Bean methods) will be registered later — during refresh(), when ConfigurationClassPostProcessor processes @ComponentScan, @Import, @Bean, and auto-configuration.

3.5. Diagram: from createApplicationContext() to ApplicationPreparedEvent

run()
 │
 ├─ createApplicationContext()
 │     └─ ApplicationContextFactory.create(webApplicationType)
 │           ├─ SERVLET   → AnnotationConfigServletWebServerApplicationContext
 │           ├─ REACTIVE  → AnnotationConfigReactiveWebServerApplicationContext
 │           └─ NONE      → AnnotationConfigApplicationContext
 │
 ├─ context.setApplicationStartup()
 │
 └─ prepareContext()
       │
       ├─ context.setEnvironment(environment)
       │
       ├─ postProcessApplicationContext()
       │     ├─ BeanNameGenerator
       │     ├─ ResourceLoader
       │     └─ ConversionService
       │
       ├─ applyInitializers()
       │     └─ ApplicationContextInitializer.initialize(context)
       │           (ConfigurationWarnings, ContextId, custom ones)
       │
       ├─ listeners.contextPrepared()
       │     └─ ApplicationContextInitializedEvent
       │
       ├─ logStartupProfileInfo()
       │
       ├─ beanFactory.registerSingleton("springApplicationArguments")
       ├─ beanFactory.registerSingleton("springBootBanner")
       │
       ├─ setAllowBeanDefinitionOverriding()
       │
       ├─ getAllSources() → {MyApplication.class}
       │
       ├─ load(context, sources)
       │     └─ BeanDefinitionLoader.load()
       │           └─ AnnotatedBeanDefinitionReader.register(MyApplication.class)
       │                 → BeanDefinition for @SpringBootApplication
       │
       └─ listeners.contextLoaded()
             └─ ApplicationPreparedEvent
                   │
                   ▼
             refreshContext()   ← Part 5
Enter fullscreen mode Exit fullscreen mode

3.6. Spring Boot 3 vs Spring Boot 4: What Changed

ApplicationContextFactory — removed

In Spring Boot 3, ApplicationContextFactory is a public interface, part of the spring-boot core. In Spring Boot 4, it has been removed. Instead, a modular system is at work: each module (spring-boot-webmvc, spring-boot-webflux, spring-boot-core) registers its own context via META-INF/spring/...imports and spring.factories.

AbstractApplicationContextFactory is marked as @Deprecated(since="6.0", forRemoval=true) and is scheduled for removal. In other words, a transition period: it's still there in 3.x, gone in 4.x.

prepareContext() — almost unchanged

The logic of prepareContext() has stayed the same in Boot 4. The only thing that changed is where the context comes from — in Boot 3, ApplicationContextFactory was a field of SpringApplication, while in Boot 4 the context is created via the modular ApplicationContextFactory shipped with the corresponding starter.

BeanDefinitionLoader — unchanged

This class hasn't changed in either 3.x or 4.x. It's still package-private, still a facade over the three readers.

Starters

Boot 3 Boot 4
spring-boot-starter-web spring-boot-starter-webmvc
spring-boot-starter-webflux spring-boot-starter-webflux (unchanged)

3.7. Key Takeaways

  1. ApplicationContextFactory is the context-selection strategy. In Boot 3 it's a public interface; in Boot 4 it's removed in favor of modularity.
  2. prepareContext() is 10 steps, each of which can be extended: Initializer, BeanNameGenerator, ResourceLoader, ConversionService.
  3. ApplicationContextInitializer is the only place where you can intervene in the context before any beans are loaded.
  4. BeanDefinitionLoader doesn't create beans — it only registers the BeanDefinition for the primary source. The rest is the job of ConfigurationClassPostProcessor inside refresh().
  5. springApplicationArguments and springBootBanner are registered as singletons directly, bypassing BeanPostProcessors.
  6. ApplicationPreparedEvent is the last event before refresh(). After it, the context is "frozen" against external changes.
  7. Boot 4: ApplicationContextFactory is gone, the context is determined by the modules. prepareContext() and BeanDefinitionLoader are unchanged.

Top comments (0)