Spring Framework & the IoC Container

Beans & Bean Definitions

18 min Lesson 4 of 13

Beans & Bean Definitions

In Spring, any object that is managed by the IoC container is called a bean. That is the entire definition — but the implications run deep. When you hand an object over to Spring to manage, Spring owns its full lifecycle: it instantiates the object, wires its dependencies, applies any post-processing, and eventually destroys it when the application shuts down. Understanding what makes something a bean, how beans are described to the container, and what happens to them at runtime is the foundation of every Spring application you will ever write.

What Exactly Is a Bean?

A bean is simply a Java object created, configured, and managed by the Spring ApplicationContext. There is nothing special about the class itself — it does not need to implement a Spring interface or extend a Spring base class. A UserRepository, an EmailService, a custom Clock — any of these can be a bean. The difference between a bean and a regular object is who creates it: Spring does, not your code.

Why does this matter? When Spring creates an object, it can inject its dependencies automatically, apply cross-cutting concerns (transactions, security, caching) via proxies, and guarantee that only one instance exists across the application — without you writing a single line of singleton boilerplate.

The Bean Definition — Spring's Blueprint

Before Spring can create a bean it needs a bean definition: a metadata record that describes the bean's class, its scope, its dependencies, any initialization or destruction methods, and other configuration details. You rarely interact with bean definitions directly — you express them via annotations or Java config, and Spring translates them into BeanDefinition objects internally. But knowing what information lives in a bean definition helps you understand every configuration option Spring exposes.

A bean definition captures:

  • Class — which Java class to instantiate.
  • Name / ID — the identifier used to look up the bean.
  • Scopesingleton (one shared instance, the default) or prototype (a new instance on every request), among others.
  • Constructor arguments and property values — what to inject.
  • Initialization & destruction callbacks — methods to call after wiring and before shutdown.
  • Lazy initialization flag — whether to create the bean immediately at context startup or only on first use.

Defining Beans with @Bean in a @Configuration Class

The most explicit way to define a bean is a @Bean method inside a @Configuration class. Spring calls this method once (for singleton-scoped beans), stores the returned object, and registers it under the method name as the bean name.

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class AppConfig { // Bean name: "userRepository" @Bean public UserRepository userRepository() { return new JdbcUserRepository(dataSource()); } // Bean name: "dataSource" @Bean public javax.sql.DataSource dataSource() { var cfg = new com.zaxxer.hikari.HikariConfig(); cfg.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb"); cfg.setUsername(System.getenv("DB_USER")); cfg.setPassword(System.getenv("DB_PASS")); return new com.zaxxer.hikari.HikariDataSource(cfg); } // Bean name: "userService" — depends on userRepository @Bean public UserService userService() { // Spring intercepts this call and returns the existing singleton bean return new UserService(userRepository()); } }
CGLIB proxy magic: When userService() calls userRepository(), you might expect a second JdbcUserRepository to be created. It is not. Spring subclasses your @Configuration class with CGLIB and intercepts inter-bean method calls, returning the already-created singleton. This is why @Configuration classes must not be final.

Defining Beans with @Component (Annotation-Driven)

For your own classes, marking them with @Component (or one of its stereotypes) and enabling component scanning is usually more concise. Spring scans the specified packages, finds annotated classes, and registers a bean definition for each one automatically.

import org.springframework.stereotype.Repository; @Repository // stereotype of @Component — also activates persistence exception translation public class JdbcUserRepository implements UserRepository { private final javax.sql.DataSource dataSource; // Spring sees one constructor: automatically injects the DataSource bean public JdbcUserRepository(javax.sql.DataSource dataSource) { this.dataSource = dataSource; } @Override public User findById(long id) { // ... JDBC logic return null; } }
import org.springframework.stereotype.Service; @Service // stereotype of @Component — signals business-layer intent public class UserService { private final UserRepository repo; public UserService(UserRepository repo) { this.repo = repo; } public User getUser(long id) { return repo.findById(id); } }

With component scanning active (covered in detail in lesson 6), both classes above become beans automatically. The container detects the single-constructor pattern and injects the matching dependency without any explicit wiring instruction.

Bean Scope: Singleton vs Prototype

Scope controls how many instances the container maintains and when they are created.

  • Singleton (default): One shared instance per container. Created at startup (unless lazy), reused for every injection point. Suitable for stateless services, repositories, and most application components.
  • Prototype: A brand-new instance is created every time the bean is requested or injected. Suitable for stateful, non-thread-safe objects that must not be shared.
import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; @Component @Scope("prototype") // or: @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class ReportBuilder { private final List<String> lines = new ArrayList<>(); public void addLine(String line) { lines.add(line); } public String build() { return String.join("\n", lines); } }
Prototype injected into singleton — a classic pitfall: If a singleton bean holds a reference to a prototype bean, it receives one prototype instance at startup and keeps it forever — defeating the purpose of prototype scope. Use ApplicationContext.getBean() or a Provider<ReportBuilder> to request a fresh instance each time.

Lifecycle Callbacks: @PostConstruct and @PreDestroy

Spring can call methods on your bean after all dependencies have been injected and before the context shuts down. These are the standard hooks for initializing resources (opening a connection pool, warming a cache) and releasing them (closing connections, flushing buffers).

import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import org.springframework.stereotype.Component; @Component public class CacheWarmer { private final ProductRepository repo; public CacheWarmer(ProductRepository repo) { this.repo = repo; } @PostConstruct public void warmUp() { // Called after injection, before the app handles any requests repo.findAll().forEach(this::cache); System.out.println("Cache warmed with " + /* count */ " products"); } @PreDestroy public void shutdown() { // Called when the ApplicationContext is closing System.out.println("Flushing cache before shutdown"); } private void cache(Object p) { /* ... */ } }
Jakarta, not javax: Since Spring 6 (and Spring Boot 3), the annotations moved from javax.annotation.* to jakarta.annotation.*. Always use the jakarta import in new projects.

Lazy Initialization

By default, singleton beans are created eagerly — at context startup. This catches misconfiguration errors immediately (a missing bean dependency fails fast). Sometimes you want a bean to be created only on first use, for example if it connects to an external service that may not be available at startup. Add @Lazy:

import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @Lazy @Service public class ExternalPaymentClient { // Expensive init — only runs when this bean is first injected or looked up public ExternalPaymentClient() { System.out.println("Connecting to payment gateway..."); } }

In large applications you can set lazy as the default for all beans with @ComponentScan(lazyInit = true) or spring.main.lazy-initialization=true in application.properties, which improves startup time at the cost of moving errors from startup to first use.

Summary

A Spring bean is any object whose lifecycle is managed by the IoC container. Each bean is backed by a bean definition — a blueprint specifying the class, scope, dependencies, and lifecycle callbacks. You express bean definitions either explicitly via @Bean methods in @Configuration classes, or implicitly by annotating your own classes with @Component (or a stereotype) and enabling component scanning. The default singleton scope is correct for stateless collaborators; prototype scope exists for inherently stateful objects. Lifecycle hooks (@PostConstruct, @PreDestroy) give you clean entry and exit points for resource management. Every other Spring feature — dependency injection, AOP, transactions — builds on this bean model.