Tech Blog

A detailed analysis of the Spring Core module

A journey into the heart of the framework to understand and optimize the use of Spring Context

Edoardo Patti
Technical Lead
Andrea D'Angeli
Junior Backend Developer
Flavio Cassarà
Junior Backend Developer
9 minutes read
backend, java, and spring-context-6.2.0-SNAPSHOT
This article is also available in Italiano 🇮🇹

Introduction

In this article, we will provide a high-level overview of the Spring Framework, explaining its historical motivations and architectures. We will then proceed to analyze in detail the functioning of its core module known as the Spring Context, we will conclude by indicating some techniques for interacting with it, modifying its behavior at runtime.

Contents

Spring Framework

Spring represents a family of projects useful for creating Java Enterprise applications, hence it is said to be a modular framework, where each module can be used to address specific needs, allowing developers to focus mainly on business logic by delegating architectural aspects to the framework. Today, it is an essential tool for Java application development, especially in the web domain, and it stands out for its powerful dependency management mechanism. The framework originated in 2003, under the guidance of Rod Johnson, as a response to the complexity of Java Enterprise specifications. Although this might suggest that they are direct competitors, the reality is that these two worlds are complementary. Spring's goal is not to be compliant with Java Enterprise specifications but to integrate with some of them and use them as support for the framework itself. The foundational principles of Spring include allowing developers choice at every level, paying particular attention to backward compatibility, as well as API design to make them as intuitive as possible, and maintaining a high standard of code quality. It's worth noting that the framework has always focused on this aspect, making it one of the most well-documented and self-descriptive software.

Spring Context

The core module of Spring is represented by Spring Context, and its main function is to create applications based on the IoC (Inversion of Control) pattern implemented through DI (Dependency Injection). With this pattern, the control of dependencies and their implementations is no longer the responsibility of the object instances that use them but is delegated to a third-party component provided by the framework, known as a container. This transfer of responsibility allows business objects to not depend on a specific implementation or choose one at runtime, effectively promoting the development of applications in total accordance with SOLID principles. At the heart of this mechanism, as mentioned earlier, is a component known as a container, inside which the so-called "beans" are instantiated and managed for their entire lifecycle. But what are beans really, and how is their creation achieved?

While it is sufficient to say that beans can be either regular POJOs or classes with specific annotations to answer the first question, to provide a detailed answer to the second question, we will delve into the core of the Spring Framework to discover the process through which beans are instantiated and managed. We will focus on the details of the AnnotationConfigApplicationContext class, which represents the cornerstone through which a Spring application can be configured using annotations. Among the main interfaces orchestrating the flow are undoubtedly the BeanDefinitionRegistry, responsible for creating and managing bean definitions, the BeanFactory, responsible for creating and managing beans, and the ApplicationContext, which precisely coordinates the APIs provided by the previous interfaces as well as their implementations.

By extending the GenericApplicationContext class, AnnotationConfigApplicationContext not only implements BeanDefinitionRegistry with the fundamental method registerBeanDefinition(String beanName, BeanDefinition beanDefinition) but also possesses an instance variable of type DefaultListableBeanFactory. It should be noted that GenericApplicationContext, in turn, extends AbstractApplicationContext, which declares the method createEnvironment() that returns an instance of StandardEnvironment based on system properties System.getProperties() and environment System.getEnv(), where the former takes precedence over the latter. Finally, StandardEnvironment extends ApplicationContext, which implements ApplicationEventPublisher and is therefore capable of generating events through the publishEvent(Object event) method.

UML class diagram
UML class diagram

BeanDefinitionRegistry

The BeanDefinitionRegistry is an interface that declares several methods for working with BeanDefinitions, including those for registering new ones, retrieving them, and removing them. As mentioned, these methods are implemented within GenericApplicationContext through the methods provided by DefaultListableBeanFactory, which in turn implements this interface.

A BeanDefinition describes a class declared as a bean, including some information such as:

  • beanClass, representing the fully qualified class name
  • scope defaulting to singleton
  • initMethodName
  • destroyMethodName
  • qualifiers if specified through annotations
  • source representing the path to the .class file
  • metadata representing an object of type SimpleAnnotationMetadata containing metadata of declared methods, and possibly superclass

AnnotatedBeanDefinitionReader e ClassPathBeanDefinitionScanner

We can instantiate an AnnotationConfigApplicationContext in two different ways. The first approach involves passing a list of component classes directly to the constructor. By component, we mean any specialization of the @Component annotation (@Service, @Configuration, @Controller, etc.).

public AnnotationConfigApplicationContext(Class<?>... componentClasses) {
  this();
  register(componentClasses);
  refresh();
}

The second approach involves passing a list of basePackages to the constructor. In this way, all classes in the specified packages and their sub-packages will be scanned.

public AnnotationConfigApplicationContext(String... basePackages) {
  this();
  scan(basePackages);
  refresh();
}

In both cases, the first instruction is to invoke the parameterless constructor, where instances of AnnotatedBeanDefinitionReader and ClassPathBeanDefinitionScanner are instantiated. These two objects require a BeanDefinitionRegistry to be instantiated, which in this case will be the instance of AnnotationConfigApplicationContext itself.

public AnnotationConfigApplicationContext() {
  ...
  this.reader = new AnnotatedBeanDefinitionReader(this);
  ...
  this.scanner = new ClassPathBeanDefinitionScanner(this);
}

In a simple Spring application with only the spring-context dependency, when the reader is instantiated, default BeanDefinitions are added to the BeanDefinitionRegistry through the registerAnnotationConfigProcessors() method of the AnnotationConfigUtils class, called in the constructor of the reader itself. These definitions represent the following classes:

  • ConfigurationClassPostProcessor used to process classes annotated with @Configuration. Registered by default when annotation-based configuration is enabled.
  • DefaultEventListenerFactory used to support the @EventListener annotation
  • EventListenerMethodProcessor used to register methods annotated with @EventListener as instances of ApplicationListener
  • AutowiredAnnotationBeanPostProcessor used to support autowiring via constructor, methods, and fields.

Register

As seen earlier in the first approach, when we pass a list of componentClasses, the register(componentClasses) method of the AnnotatedBeanDefinitionReader class will be invoked.

@Override
public void register(Class<?>... componentClasses) {
	...
	this.reader.register(componentClasses);
	...
}

Within this flow, the BeanDefinitions will be registered. The process begins with iterating through the list of classes provided as input, each of which is a potential candidate to become a bean.

public void register(Class<?>... componentClasses) {
		for (Class<?> componentClass : componentClasses) {
         registerBean(componentClass);
    }
}

To start the transformation process, inside the doRegisterBean() method, an AnnotatedGenericBeanDefinition is instantiated. Key parameters for the AnnotatedGenericBeanDefinition are configured, such as its scope and name, if not explicitly provided. The scope defines the lifecycle and visibility of a bean managed by the Spring container. There are different types of scopes, each determining how the bean is instantiated and maintained within the application. Here are some of the most common scopes:

  • Singleton: This is the default scope. Spring creates a single instance of the bean for the application and keeps it in memory. All requests for that bean will always return the same instance.
  • Prototype: Spring creates a new instance of the bean each time it is requested, so each request will return a separate instance of the bean.
  • Request: The bean exists only within the lifecycle of a single HTTP request. A new bean is created for each HTTP request and destroyed at the end of the request.
  • Session: The bean exists within the lifecycle of a single user session. A new bean is created for each user session and destroyed at the end of the session.

After setting the scope, common annotations like Primary, Fallback, Role, Lazy, Description, and DependsOn are interpreted and applied through the processCommonDefinitionAnnotations() method.

private <T> void doRegisterBean(Class<T> beanClass, ...) {

    AnnotatedGenericBeanDefinition abd = new AnnotatedGenericBeanDefinition(beanClass);
    ...
    abd.setScope(scopeMetadata.getScopeName());
    String beanName = (name != null ? name : this.beanNameGenerator.generateBeanName(abd, this.registry));
    AnnotationConfigUtils.processCommonDefinitionAnnotations(abd);
	...
    BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(abd, beanName);
    BeanDefinitionReaderUtils.registerBeanDefinition(definitionHolder, this.registry);
}

Once the preparation phase is completed, a BeanDefinitionHolder is instantiated. Finally, the definition is officially registered through the registerBeanDefinition() method.

Scan

When an AnnotationConfigApplicationContext is instantiated by passing an array of strings representing the base packages to the constructor, the situation will be similar.

@Override
public void scan(String... basePackages) {
    ...
    this.scanner.scan(basePackages);
    ...
}

When the ApplicationContext is instantiated, the doScan(basePackages) method of the ClassPathBeanDefinitionScanner class will be invoked, in which we scan all the potential beanDefinitions within each basePackage. Inside it, the findCandidateComponents(basePackage) method is called, which, starting from an array of strings representing the basePackages, initially constructs an array of Resource, each of which points to the classes in the packages, from which an array of BeanDefinitions will be created. For each of these, we set the scope and beanName. Subsequently, as already seen in the flow of register(), we invoke the processCommonDefinitionAnnotations() method of the AnnotationConfigUtils class where the aforementioned common annotations are interpreted and applied to the BeanDefinition.

Afterward, there will be a checkCandidate for each candidate that will check, among other things, that the registry does not already contain that definition. Once the definition is checked, it will be registered within the beanFactory.

protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
    ...
    Set<BeanDefinitionHolder> beanDefinitions = new LinkedHashSet<>();
    for (String basePackage : basePackages) {
        Set<BeanDefinition> candidates = findCandidateComponents(basePackage);
        for (BeanDefinition candidate : candidates) {
            ...
            candidate.setScope(scopeMetadata.getScopeName());
            String beanName = this.beanNameGenerator.generateBeanName(candidate, this.registry);
            ...
            if (checkCandidate(beanName, candidate)) {
                BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
                beanDefinitions.add(definitionHolder);
                registerBeanDefinition(definitionHolder, this.registry);
            }
        }
    }
    return beanDefinitions;
}

Refresh

Once all the definitions are registered through one of the two flows described above, we land on the refresh() method. Within this method, the actual creation of the beans occurs.

This process is characterized by several steps. Initially, the beanFactory is retrieved through the obtainFreshBeanFactory() method. Once obtained, we enter the invokeBeanFactoryPostProcessors(beanFactory) method, which will call its namesake in PostProcessorRegistrationDelegate.

@Override
public void refresh() throws BeansException, IllegalStateException {
    ...
    try {
        ...
        ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
        ...
        try {
            ...
            invokeBeanFactoryPostProcessors(beanFactory);
            registerBeanPostProcessors(beanFactory);
            ...
            finishBeanFactoryInitialization(beanFactory);
            finishRefresh();
            ...
        }
    }
}

BeanFactoryPostProcessor

In the PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors() method, BeanFactoryPostProcessors will be instantiated, which are special beans through which you can modify the BeanDefinitions previously registered. For example, they can be used for various critical tasks such as configuring global properties, resolving dependencies, or dynamically adding definitions to the context.

It's important to note that BeanFactoryPostProcessors implement the functional interface BeanFactoryPostProcessor, thus defining a precise contract for manipulating the application context configuration. You can establish priorities among different BeanFactoryPostProcessors, for example, by using the PriorityOrdered interface to manage the order of configurations. This ensures careful handling of dependencies and properties in the application environment.

public class FactoryPostProcessor implements BeanFactoryPostProcessor, PriorityOrdered {

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        System.out.println(Arrays.toString(beanFactory.getBeanDefinitionNames()));
    }

    @Override
    public int getOrder() {return 0; }
}

BeanPostProcessor

After instantiating and invoking the BeanFactoryPostProcessors, Spring will also handle the instantiation of BeanPostProcessors, which implement the BeanPostProcessor interface and, unlike BeanFactoryPostProcessors, act at the level of each individual bean after it has been instantiated. This happens within the registerBeanPostProcessors() method.

You can declare BeanPostProcessors and BeanFactoryPostProcessors as static to force Spring to initialize them before any other bean. To better understand, consider an example of declaring a BeanPostProcessor:

public class Processor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        System.out.println(bean.getClass().getName()+" Before callback");
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        System.out.println(bean.getClass().getName()+" After callback");
        return bean;
    }
}

In this example, consider defining a BeanPostProcessor that adjusts the desired behavior of beans after the initialization phase, but before or after the execution of initialization callbacks (afterPropertySet() and init-method). This allows for dynamicizing and modifying the final instance of the bean, enabling detailed customization of its properties and behaviors. It's important to emphasize that the application of such a BeanPostProcessor occurs only after the bean has been created.

Finally, the phase of instantiating other beans, which constitute the main components of the application (annotated with @Component, @Service, @Repository, etc.), always occurs within the refresh method, more precisely in the finishBeanFactoryInitialization() method.

The process starts with the invocation of the doGetBean() method of the AnnotationConfigApplicationContext's beanFactory.

protected <T> T doGetBean(
        String name, @Nullable Class<T> requiredType, @Nullable Object[] args, boolean typeCheckOnly)
        throws BeansException {

    String beanName = transformedBeanName(name);
    ...
    RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName);
    ...
    return createBean(beanName, mbd, args);
  }
  ...
}

Within the createBean() method lies the heart of the process. In the method, defined in the AbstractAutowireCapableBeanFactory class, the doCreateBean() method is called. In this phase, a preliminary check is made to see if the bean to be instantiated is a singleton and if it is already present in the singleton bean cache. If it's not in the cache, the createBeanInstance() method of the AbstractAutowireCapableBeanFactory class is called to handle the creation of the bean. Here, the actual instantiation process occurs using the instantiateUsingFactoryMethod() method of the ConstructorResolver class.

If we're using constructor dependency injection, this is where dependencies are imported into the returned wrapper instance. On the other hand, if we're using the @Autowired annotation for dependency injection, the process is slightly different. Dependencies will be initialized in the populateBean() method of the AbstractAutowireCapableBeanFactory class, more precisely within the postprocessProperty() method. This latter method, in turn, invokes doResolveDependency() to resolve the required dependencies.

Once the populateBean() method is finished, the initializeBean() method of the AbstractAutowireCapableBeanFactory class is called, taking the BeanName and the previously created bean instance. This will be the method responsible for applying the BeanPostProcessors in order to dynamicize and modify the final instance of the bean.

After invoking initializeBean(), two phases are activated that define the behavior of the newly initialized bean:

  1. applyBeanPostProcessorsBeforeInitialization: This method applies the BeanPostProcessors by acting according to the logic defined in the postProcessBeforeInitialization() method. Here, the BeanPostProcessors have the opportunity to manipulate and customize the bean before the so-called initialization callbacks such as afterPropertySet() and any custom init-method are executed.
@Nullable
protected Object applyBeanPostProcessorsBeforeInstantiation(Class<?> beanClass, String beanName) {
    for (InstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().instantiationAware) {
       Object result = bp.postProcessBeforeInstantiation(beanClass, beanName);
       if (result != null) {
          return result;
       }
    }
    return null;
}
  1. applyBeanPostProcessorsAfterInitialization: In this phase, the BeanPostProcessors operate based on the logic contained in the postProcessAfterInitialization() method. Here, the BeanPostProcessors intervene following the aforementioned callbacks.
@Override
public Object applyBeanPostProcessorsAfterInitialization(Object existingBean, String beanName)
       throws BeansException {

    Object result = existingBean;
    for (BeanPostProcessor processor : getBeanPostProcessors()) {
       Object current = processor.postProcessAfterInitialization(result, beanName);
       if (current == null) {
          return result;
       }
       result = current;
    }
    return result;
}

Finally, once all beans have been initialized with their dependencies, and the BeanPostProcessors have been applied, the refresh flow concludes with the finishRefresh() method. This method performs a series of essential operations:

  1. It begins by cleaning the caches and initializing the LifecycleProcessor, which implements the onRefresh() and onClose() methods managing the two phases.
  2. Subsequently, it notifies the LifecycleProcessor of the completed refresh of the applicationContext.
  3. Finally, it publishes a ContextRefreshedEvent, signaling that the context is now fully initialized and ready for use.

In summary, finishRefresh() is the endpoint that ensures the context is ready and operational after the refresh.

ApplicationListener

After discussing the importance of BeanFactoryPostProcessor and BeanPostProcessor in the initialization and manipulation of beans within the application, it is important to also explore the role of ApplicationListener<E extends ApplicationEvent>. These provide a mechanism for event handling, allowing for the definition of specific actions to be executed in response to events such as application startup, shutdown, refresh, or closure.

Below is a list of possible specializations of ApplicationEvent:

  • ApplicationContextEvent: Every application context event.
  • ContextClosedEvent: Application shutdown.
  • ContextRefreshedEvent: Application context refresh.
  • ContextStartedEvent: Application startup.
  • ContextStoppedEvent: Application interruption.
  • PayloadApplicationEvent: Sending customized payloads with events.
public class CustomApplicationListener implements ApplicationListener<ApplicationContextEvent> {

    @Override
    public void onApplicationEvent(ApplicationContextEvent event) {
        System.out.println(event.getClass() + " Event Class");
    }
}

Thus, the use of ApplicationListener adds an additional layer of customization and control over the application's behavior in response to events occurring during its execution.

Conclusion

In conclusion, we have explored in detail the internal process of bean creation within the Spring framework. From when we instantiate an AnnotationConfigApplicationContext to the completion of the finishRefresh() method, we have analyzed every critical phase of the bean lifecycle and the bean factory, and how to interact at runtime with these two essential components respectively through BeanPostProcessors and BeanFactoryPostProcessors.

We have seen how Spring handles the registration of beans through annotation reading, class and package scanning, and finally how it completes the initialization process, ensuring that the context is fully ready for use.

This deep dive has provided us with a comprehensive overview of how Spring's internal workings and helped us better understand how this powerful and flexible framework implements the IoC pattern through dependency management and bean creation in Java applications.

written by
Edoardo Patti
Technical Lead
In SMC he holds the role of Technical Lead
Andrea D'Angeli
Junior Backend Developer
In SMC he holds the role of Junior Backend Developer
Flavio Cassarà
Junior Backend Developer
In SMC he holds the role of Junior Backend Developer. He has a bachelor's degree in computer science from the University of Palermo

You might also like…