Where Do Spring Beans Actually Come From?

Where Do Spring Beans Actually Come From?

When a Spring Boot application starts, the ApplicationContext doesn't magically know about your services, repositories or configuration classes. It begins as an empty container that gradually discovers, registers and instantiates beans through a series of well-defined phases.

Most developers interact with only a handful of registration mechanisms:

  • @Component
  • @Service
  • @Repository
  • @Configuration
  • @Bean

These are enough for most business applications.

However, Spring itself — and especially Spring Boot auto-configuration — relies on far more sophisticated registration APIs to dynamically contribute beans before the application is fully initialized.

Understanding these mechanisms makes the startup process far less mysterious.

⚡ TL;DR (Quick Recap)

  • Bean registration happens throughout the application startup lifecycle — not in a single step.
  • Different registration mechanisms exist for different responsibilities, ranging from application development to framework infrastructure.
  • Most applications only need @Component and @Bean, while libraries and Spring Boot itself frequently rely on programmatic registration APIs.

The Bean Registration Timeline

It helps to think of bean registration not as a grab-bag of unrelated annotations, but as a pipeline — and one detail trips people up: ApplicationContextInitializer runs before everything else, not somewhere in the middle. Component scanning, @Configuration/@Bean processing, @Import resolution and any custom BeanDefinitionRegistryPostProcessor all happen together, inside a step of context.refresh(), not as separate sequential phases.

Application Starts


ApplicationContextInitializer (runs BEFORE refresh — context is "unpopulated" or "unrefreshed")


context.refresh() begins

├── Component Scan
├── @Configuration / @Bean
├── @Import (incl. ImportSelector, ImportBeanDefinitionRegistrar)
└── BeanDefinitionRegistryPostProcessor
│ (all four resolved together, in one bean-definition-registration step)

Bean Instantiation


Application Ready


registerBean() / registerSingleton() (dynamic, anytime after startup)

Each stage introduces a new opportunity to contribute beans into the ApplicationContext — but only the last one, programmatic registration, can happen after the application is already running.

Component Scanning — The Everyday Registration Mechanism

The most common registration mechanism is component scanning. Classes annotated with one of Spring’s stereotype annotations are automatically discovered during classpath scanning.

@Component
class NotificationComponent { }
@Service
class GreetingService { }
@Repository
class UserRepository { }

These annotations are functionally similar — they all register a singleton bean — but communicate different architectural responsibilities.

Scoping is also controlled here. A prototype-scoped bean produces a new instance every time it’s requested from the container, unlike the singleton default:

@Component
@Scope("prototype")
class PrototypeBean { }

For application code, component scanning is typically the preferred registration mechanism because it keeps configuration minimal and keeps your metadata right where the implementation lives.

Factory Methods with @Bean

Not every bean can — or should — be annotated directly. Third-party classes, complex initialization logic or conditional creation are often better handled through configuration classes.

@Configuration(proxyBeanMethods = false)
class AppConfig {
@Bean
MyService beanFromConfig() {
return new MyService("registered-via-@Bean");
}
}
proxyBeanMethods = false is generally preferred to unless you specifically need calling one @Bean method from another to return the same container-managed singleton. It skips CGLIB proxying of the config class, which is faster and avoids a class of subtle bugs around inter-bean method calls.

Factory methods unlock capabilities that aren’t available through component scanning alone:

  • @Primary — selecting the default candidate
  • @Qualifier — choosing a specific dependency
  • @Lazy — deferred instantiation
  • @ConditionalOnProperty — environment-driven registration
  • FactoryBean<T> — indirect object creation

Selecting between multiple implementations is straightforward:

@Bean
@Primary
PaymentGateway stripeGateway() {
return new StripeGateway();
}
@Bean
PaymentGateway paypalGateway() {
return new PaypalGateway();
}
@Bean
MyService orderServiceLabel(@Qualifier("paypalGateway") PaymentGateway gateway) {
// @Qualifier overrides @Primary for this specific injection point
return new MyService("uses -> " + gateway.charge());
}

While component scanning favors convention, @Bean favors explicit control.

Importing Configuration

As applications grow, configuration often becomes modular. The @Import annotation lets one module contribute beans into another application's context. Spring supports three import strategies — but before looking at them, one setup detail matters more than it looks:

@Import only proves its value outside the scanned package. If the class you're importing is a static nested @Configuration class living inside a package that's already covered by @ComponentScan (which @SpringBootApplication implies for its own package), Spring's scanner will pick it up on its own — with or without @Import. The examples below live in a separate imported sub-package specifically so the @Import mechanism is actually doing the work, rather than being redundant with scanning.

Importing another configuration class

// io.github.mm.beans.imported.ImportedConfig — NOT in the scanned root package
@Configuration
public class ImportedConfig {
@Bean
public MyService beanFromImportedConfig() {
return new MyService("registered-via-@Import(@Configuration)");
}
}
@Configuration
@Import(io.github.mm.beans.imported.ImportedConfig.class)
public class MainAppConfig { }

This is simply another @Configuration class whose @Bean methods become part of the current application's context. This approach is commonly used by reusable libraries that ship a @Configuration class you're expected to @Import explicitly rather than have auto-scanned.

Using ImportSelector

Sometimes the imported configuration isn’t known until runtime. An ImportSelector decides which configuration classes should be loaded:

class MyImportSelector implements ImportSelector {
@Override
public String[] selectImports(AnnotationMetadata metadata) {
return new String[] {
"io.github.mm.beans.imported.SelectedConfig"
};
}
}

Spring Boot’s own auto-configuration mechanism builds on this idea — but with one important upgrade worth knowing about: AutoConfigurationImportSelector doesn't implement plain ImportSelector, it implements DeferredImportSelector. A DeferredImportSelector is resolved after every other @Configuration class has been fully processed. That deferral is exactly what makes @ConditionalOnMissingBean work reliably in auto-configuration — Spring Boot needs to know what beans you've already defined before it decides whether to back off and skip its own defaults. A plain ImportSelector doesn't get that guarantee.

Using ImportBeanDefinitionRegistrar

For complete control, Spring allows registering bean definitions directly:

class MyRegistrar implements ImportBeanDefinitionRegistrar {
@Override
public void registerBeanDefinitions(
AnnotationMetadata metadata,
BeanDefinitionRegistry registry) {
var definition = BeanDefinitionBuilder
.genericBeanDefinition(MyService.class)
.addConstructorArgValue("registered-via-@Import(ImportBeanDefinitionRegistrar)")
.getBeanDefinition();
registry.registerBeanDefinition("beanFromImportRegistrar", definition);
}
}

Unlike ImportSelector, this API doesn't import configuration classes — it contributes BeanDefinition objects directly.

Registering Bean Definitions During Startup

One of the most powerful extension points is BeanDefinitionRegistryPostProcessor. Rather than creating bean instances, it contributes bean definitions before the container begins instantiating objects.

@Bean
static BeanDefinitionRegistryPostProcessor registrar() {
return new BeanDefinitionRegistryPostProcessor() {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
var definition = BeanDefinitionBuilder
.genericBeanDefinition(MyService.class)
.addConstructorArgValue("registered-via-RegistryPostProcessor")
.getBeanDefinition();
registry.registerBeanDefinition("beanFromRegistryPostProcessor", definition);
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
// no-op - required by the parent BeanFactoryPostProcessor interface
}
};
}

That static keyword is not optional. A non-static @Bean method requires its enclosing @Configuration class to be instantiated first — but BeanDefinitionRegistryPostProcessors must run before most bean instantiation happens, including the instantiation of the config class itself. Drop static here and Spring will log a warning that the post-processor bean was created too early to be fully processed by other BeanFactoryPostProcessors, and its behavior becomes unreliable. If you only remember one gotcha from this section, make it this one.

A BeanDefinition is metadata describing how a bean should be created — a blueprint, not the finished building. Only later, during instantiation, does Spring build the actual object.

Programmatic Registration

Not every bean has to be known before the application starts. Spring also provides APIs for registering beans programmatically, both before and after the context exists.

Before refresh(): ApplicationContextInitializer

An ApplicationContextInitializer executes before the context is refreshed — earlier than component scanning, @Bean processing or @Import resolution:

app.addInitializers(context -> {
if (context instanceof GenericApplicationContext gac) {
gac.registerBean(
"serviceFromInitializer",
MyService.class,
() -> new MyService("initializer"));
}
});

This is frequently used by infrastructure code that needs to contribute beans before initialization begins.

After startup: GenericApplicationContext

Once the application is running, GenericApplicationContext still allows new beans to be registered:

gac.registerBean(
"serviceFromSupplier",
MyService.class,
() -> new MyService("supplier"));

Or register a complete BeanDefinition directly:

gac.registerBeanDefinition("serviceFromBeanDefinition", definition);

Registering an already-built object

Spring can also expose an already constructed object as a singleton:

ctx.getBeanFactory().registerSingleton("serviceFromSingleton", prebuiltInstance);

Unlike every other mechanism above, this does not perform dependency injection or invoke lifecycle callbacks (@PostConstruct, InitializingBean, BeanPostProcessors). It's simply handed to the container as-is — useful, but worth knowing that it skips the usual bean lifecycle entirely.

Which Mechanism Should You Use?

Use component scanning when:

  • Creating application services
  • Building REST controllers
  • Implementing repositories
  • Writing business logic

Use @Bean when:

  • Configuring third-party libraries
  • Performing custom initialization
  • Choosing between multiple implementations
  • Registering conditional beans

Use @Import when:

  • Building reusable modules that live outside the consuming app’s scanned packages
  • Sharing configuration across applications
  • Developing Spring Boot starters

Use programmatic registration when:

  • Developing frameworks
  • Building plugin systems
  • Registering beans dynamically at runtime
  • Integrating external infrastructure

For the vast majority of applications, @Component and @Bean remain the right choice. The more advanced APIs primarily exist to support Spring itself and the ecosystem built on top of it.

Final Takeaways

Spring’s bean registration story extends far beyond @Component.

Behind every Spring Boot application is a sequence of registration phases that gradually populate the ApplicationContext. Component scanning discovers application code, configuration classes contribute factory methods, @Import enables modular composition, registry post-processors extend the container before initialization and programmatic APIs allow beans to be added dynamically even after startup.

Most developers will only ever need a small subset of these mechanisms. But understanding the full pipeline — and getting the order right — is what makes Spring Boot auto-configuration, starters and infrastructure libraries stop feeling like magic.

You can find an example on GitHub.

Originally posted on marconak-matej.medium.com.