Skip to main content
spring internals

Bean Definition vs Bean Instance: What Spring Registers, What Spring Creates, and When

7 min read Chapter 4 of 78

Bean Definition vs Bean Instance

Spring does not create your beans when it finds them. It creates descriptions of your beans first. Then, much later, it creates the beans themselves. Two distinct phases. Two different data structures. Two different failure categories. Conflating them is the root cause of at least half the “why is my bean null” questions on Stack Overflow.

Bean Definition metadata versus live Bean Instance, showing the two phases of the container lifecycle

The Two-Phase Model

When AbstractApplicationContext.refresh() runs, it passes through thirteen internal steps. The relevant ones for this chapter:

Phase 1: Bean Definition Registration (refresh steps 2-6). Spring scans your classpath, parses your @Configuration classes, reads @Bean methods, processes auto-configuration, and imports XML if any still exists. Every discovered component, every declared bean method, every auto-configured class produces a BeanDefinition object. These objects are metadata. They describe what Spring will eventually create: the class, the scope, whether it is lazy, whether it is primary, which constructor arguments it requires. No constructors are called. No fields are injected. No @PostConstruct methods run. The bean does not exist.

Phase 2: Bean Instantiation (refresh step 11, finishBeanFactoryInitialization). Spring iterates through every registered singleton BeanDefinition and calls getBean() on each. This triggers constructor invocation, dependency injection, BeanPostProcessor callbacks, @PostConstruct execution, and proxy wrapping. Now the bean exists.

The time gap between these two phases is where BeanFactoryPostProcessor runs (CH3). It operates on definitions, not instances. This is by design. You can change the scope of a bean, swap its class, add constructor arguments, all before any constructor is called.

BeanDefinition: The Metadata Object

BeanDefinition is an interface in org.springframework.beans.factory.config. Its key properties:

public interface BeanDefinition extends AttributeAccessor, BeanMetadataElement {
    String getBeanClassName();
    String getScope();             // "singleton", "prototype", "request", etc.
    boolean isLazyInit();
    boolean isPrimary();
    String[] getDependsOn();
    String getFactoryMethodName();  // non-null for @Bean methods
    String getFactoryBeanName();    // the @Configuration class for @Bean methods
    ConstructorArgumentValues getConstructorArgumentValues();
    MutablePropertyValues getPropertyValues();
}

Spring ships three concrete implementations that matter:

ScannedGenericBeanDefinition. Created by ClassPathBeanDefinitionScanner when it finds a class annotated with @Component, @Service, @Repository, or @Controller. The bean class name is set. The factory method is null. The source is the .class file on the classpath.

ConfigurationClassBeanDefinition. Created by ConfigurationClassBeanDefinitionReader when it processes an @Bean method inside a @Configuration class. The factory bean name is the configuration class. The factory method name is the @Bean method name.

RootBeanDefinition. The internal merge target. When Spring resolves a bean definition that has a parent (from XML inheritance or @Configuration class hierarchies), it merges the child into a RootBeanDefinition. This is the actual type stored at instantiation time, regardless of what was originally registered.

For the SaaS backend, consider the tenant-aware data source:

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public DataSource tenantDataSource(TenantResolver resolver) {
        return new TenantRoutingDataSource(resolver);
    }
}

When ConfigurationClassPostProcessor parses this class, it creates a ConfigurationClassBeanDefinition with:

  • beanClassName: null (it is a factory method, not a direct class instantiation)
  • factoryBeanName: "dataSourceConfig"
  • factoryMethodName: "tenantDataSource"
  • isPrimary: true
  • scope: "singleton" (default)

No TenantRoutingDataSource object exists. No TenantResolver has been injected. The definition records intent.

DefaultListableBeanFactory: The Registry

All BeanDefinition objects are stored in DefaultListableBeanFactory, which implements BeanDefinitionRegistry. Internally, it uses a ConcurrentHashMap<String, BeanDefinition> called beanDefinitionMap and an ArrayList<String> called beanDefinitionNames that preserves registration order.

Registration order matters. When Spring iterates through singleton definitions during finishBeanFactoryInitialization, it follows the list order. This means beans registered first are instantiated first (unless dependencies force a different order via getBean() recursive calls).

You can inspect the registry programmatically:

@Component
public class RegistryInspector implements ApplicationContextAware {

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        var factory = (DefaultListableBeanFactory)
            ((ConfigurableApplicationContext) ctx).getBeanFactory();

        int total = factory.getBeanDefinitionCount();
        String[] names = factory.getBeanDefinitionNames();

        for (String name : names) {
            BeanDefinition bd = factory.getBeanDefinition(name);
            System.out.printf("Bean: %-40s | Class: %-60s | Scope: %s | Lazy: %s%n",
                name,
                bd.getBeanClassName(),
                bd.getScope().isEmpty() ? "singleton" : bd.getScope(),
                bd.isLazyInit());
        }
    }
}

In a typical Spring Boot web application with JPA, Security, and Actuator, the registry holds between 300 and 500 bean definitions. Your application contributes maybe 30 of those. The rest come from auto-configuration.

Actuator Inspection: /actuator/beans

The /actuator/beans endpoint dumps the live bean graph after instantiation. It shows:

{
  "contexts": {
    "application": {
      "beans": {
        "tenantDataSource": {
          "aliases": [],
          "scope": "singleton",
          "type": "com.saas.config.TenantRoutingDataSource",
          "resource": "class path resource [com/saas/config/DataSourceConfig.class]",
          "dependencies": ["tenantResolver"]
        }
      }
    }
  }
}

Note what this shows: the live type (TenantRoutingDataSource), not the factory method return type. The dependencies array lists the beans that were injected. The resource field tells you which configuration class or scanned component registered it.

This endpoint shows instances. It does not show definitions that were registered but never instantiated (lazy beans that have not been requested, or prototype-scoped definitions that only exist as templates).

To see bean definitions before instantiation, you need a BeanFactoryPostProcessor (CH3). There is no actuator endpoint for pre-instantiation definitions.

The Failure Mode

This confusion surfaces in real projects. Consider a developer adding a health indicator for the SaaS tenant system:

// BROKEN: Trying to read bean state during definition time
@Component
public class TenantHealthContributor implements BeanDefinitionRegistryPostProcessor {

    @Autowired  // will be null during postProcessBeanDefinitionRegistry
    private TenantResolver tenantResolver;

    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
        // tenantResolver is null here. This class is instantiated
        // before normal beans because it implements
        // BeanDefinitionRegistryPostProcessor.
        // Spring creates it early so it can modify definitions.
        // @Autowired has not run. @PostConstruct has not run.

        if (tenantResolver.getActiveTenants().isEmpty()) {
            // NullPointerException
            throw new IllegalStateException("No tenants configured");
        }
    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {}
}

The developer mixed phases. BeanDefinitionRegistryPostProcessor runs during phase 1 (definition registration). It is instantiated before normal beans. @Autowired injection happens during phase 2. The field is null.

This bug is insidious because it does not always appear. If the developer happened to test with a TenantResolver that was an eagerly-initialized static singleton outside Spring, the null reference might be masked.

The Correct Pattern

Separate the concerns. If you need to validate runtime state, use a lifecycle callback that runs after instantiation:

// CORRECT: Use SmartInitializingSingleton, which runs after all
// singleton beans are instantiated (end of phase 2)
@Component
public class TenantHealthValidator implements SmartInitializingSingleton {

    private final TenantResolver tenantResolver;

    public TenantHealthValidator(TenantResolver tenantResolver) {
        this.tenantResolver = tenantResolver;
    }

    @Override
    public void afterSingletonsInstantiated() {
        // All singletons are created. Dependencies are injected.
        // This is the right time to validate runtime state.
        if (tenantResolver.getActiveTenants().isEmpty()) {
            throw new IllegalStateException(
                "No tenants configured. Check TENANT_CONFIG_URL environment variable.");
        }
    }
}

SmartInitializingSingleton.afterSingletonsInstantiated() fires at the end of finishBeanFactoryInitialization, after every singleton has been created, injected, and post-processed. This is the correct phase for runtime validation.

If you genuinely need to operate on bean definitions (changing scope, adding definitions dynamically), implement BeanDefinitionRegistryPostProcessor and do not inject other beans. Access the registry parameter directly. Read property values from the Environment, which is available at definition time:

// CORRECT: Operating on definitions without requiring live beans
public class TenantBeanRegistrar implements BeanDefinitionRegistryPostProcessor,
                                            EnvironmentAware {
    private Environment environment;

    @Override
    public void setEnvironmentAware(Environment environment) {
        this.environment = environment;
    }

    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
        String tenantMode = environment.getProperty("saas.tenant.mode", "schema");

        if ("database".equals(tenantMode)) {
            var bd = new RootBeanDefinition(DatabasePerTenantDataSource.class);
            bd.setPrimary(true);
            registry.registerBeanDefinition("tenantDataSource", bd);
        }
    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {}
}

This registrar uses EnvironmentAware (which Spring satisfies before postProcessBeanDefinitionRegistry is called) instead of @Autowired. It operates on definitions, registering a new one conditionally. It does not touch live beans because live beans do not exist yet.

The rule is straightforward: know which phase you are in. If you hold a BeanDefinitionRegistry, you are in phase 1. Operate on metadata. If you hold an ApplicationContext or receive a callback like afterSingletonsInstantiated, you are in phase 2. Operate on instances. Crossing phases produces nulls, ordering bugs, and startup failures that the stack trace alone will not explain.