Skip to main content

Command Palette

Search for a command to run...

Spring Boot Deep Dive: Beans, Lifecycle & Dependency Injection

Updated
12 min read
Spring Boot Deep Dive: Beans, Lifecycle & Dependency Injection
N
Java & Spring Boot learner | Writing beginner-friendly technical articles | Exploring backend development

A comprehensive guide to understanding how Spring manages objects, when they come alive, and how they talk to each other.

1. What is a Bean?

In simple terms, a Bean is just a Java object managed by the Spring container - also known as the IOC (Inversion of Control) container.

Think of the IOC container as a smart factory. You tell it what objects (beans) to create, and it takes full responsibility for:

  • Creating their dependencies

  • Injecting their dependencies

  • Managing their entire lifecycle

  • Destroying them when no longer needed

IOC Container  -> Creates, Manages, and Destroys Beans

You don't call new MyClass() manually everywhere. Spring does it for you.

2. How to Create a Bean

There are two primary ways to define a bean in Spring Boot:

Way 1: Using @Component Annotation

@Component follows the "convention over configuration" approach. Spring Boot will auto-configure your beans based on conventions, reducing the need for explicit setup.

Annotations like @Controller, @Service, and @Repository are all specializations of @Component - they internally tell Spring to create and manage a bean.

@Component
public class User {
    
       String username;
       String email;

      public String getUsername(){
           return username;
      }
      
      public void setUsername(String username){
          this.username = username;
       }
       
      public String getEmail(){
          return email;
       }

      public void setEmail(String email){
          this.email = email;
       }
}

Here, Spring Boot will internally call new User() to create an instance of this class.

The problem with Parameterized Constructors

What if your class has a constructor that requires arguments?

@Component
public class User{
   
      String username;
      String email;

     public User(String username, String email){
         this.username = username;
         this.email = email;
     }
}

This will cause:

****************************
APPLICATION FAILED TO START
****************************

Why? Because Spring does not know what values to pass to the constructor parameters.

Way 2: Using @Bean Annotation

@Bean lets you provide the configuration details explicitly, telling Spring exactly how to create a bean. This is placed inside a @Configuration class.

//User.java -- no Spring annotation needed here
public class User {
     String username;
     String email;

    public User(String username, String email){
          this.username = username;
          this.email = email;
     }
}

// AppConfig.java
@Configuration
public class AppConfig {

     @Bean
     public User createUserBean(){
         return new User("defaultUsername", "defaultEmail");
      }
}

You can even define multiple beans of the same type - Spring will create a separate bean for each:

@Configuration
public class AppConfig {
   
     @Bean
     public User createUserBean(){
          return new User("defaultUsername", "defaultEmail");
     }
   
     @Bean
     public User createAnotherUserBean(){
          return new User("anotherUsername", "anotherEmail");
     }
}

3. How Spring Finds Your Beans

Once you define beans, Spring needs to discover them. There are two approaches:

1. @ComponentScan

Spring uses @ComponentScan to scan specified packages (and sub-packages) for classes annotated with @Component, @Service, @Controller, etc.

@SpringBootApplication
@ComponentScan(basePackages = "com.springbootbynandini.springblogs")
public class SpringbootApplication{
  
    public static void main(String[] args){
       SpringApplication.run(SpringbootApplication.class, args);
}
}

Note: @SpringBootApplication itself includes @ComponentScan by default, scanning the package it resides in and all sub-packages.

2. Explicit @Bean in @Configuration

As shown above - when you manually declare beans using @Bean inside a @Configuration class, Spring discovers them through that configuration.

4. When are Beans created Eager vs Lazy

Beans aren't all created at the same time. Spring supports two initialization strategies:

  • Eager -- Created at application startup

Example: Singleton-scoped beans (default)

  • Lazy -- Created when first needed/requested

Example: Prototype-scoped beans, @Lazy beans

Eager Initialization (Default)

Singleton beans are initialized when the application starts. This ensures all dependencies are wired up before any request is processed.

Lazy Initialization

Use @Lazy to delay bean creation until it's actually needed:

@Lazy
@Component
public class Order{

    public Order(){
        System.out.println("Initializing Order");
     }
}

The "Initializing Order" message won't print at startup - only when Order is first accessed.

5. The Bean Lifecycle - Step by Step

Understanding the bean lifecycle is crucial for writing clean Spring applications. Here's the complete flow:

Application Start
      |
IOC Container Started <--  Configuration Loaded
      |
Construct Bean
      |
Inject Dependency Into Constructed Bean
      |
@PostConstruct   (run custom init logic)
      |
Use the Bean
      |
@PreDestroy     (run cleanup logic)
      |
Bean Destroyed

Let's walk through each step:

Step 1: IOC Container Starts

During application startup, Spring Boot invokes the IOC container (ApplicationContext). It uses @Configuration and @ComponentScan to discover all beans that need to be created.

Step 2: Construct the Bean

Spring calls the constructor to instantiate the bean.

@Component
public class User{

    public User(){
        System.out.println("Initializing User");
     }
}

You'll see "Initializing User" printed in the logs at startup.

Step 3: Inject the Dependencies

After construction, Spring injects all required dependencies (via @Autowired). We'll cover this in depth in the Dependency Injection section.

There are three injection styles:

  • Constructor Injection

  • Setter Injection

  • Field Injection

Step 4: @PostConstruct -- Post-Initialization Hook

After dependencies are injected, Spring calls the method annotated with @PostConstruct. Use this to run any initialization logic that requires dependencies to already be available.

@Component
public class User{

   @Autowired
   Order order;

   @PostConstruct
   public void initialize(){
        System.out.println("Bean constructed and dependencies injected!");
}

   public User(){
        System.out.println("Initializing User");
   }
}

Output Order:

Initializing User
Lazy: Initializing Order
Bean constructed and dependencies injected!

Step 5: Use the Bean

The bean is fully initialized and ready to be used across your application to execute business logic.

Step 6: @PreDestroy - Pre-Destruction Hook

Before the bean is destroyed (i.e., when the application context is closed), Spring calls the method annotated with @PreDestroy. Use this for cleanup - like closing connections, releasing resources, etc.

@Component
public class User{

    @PostConstruct
    public void initialize(){
         System.out.println("Post Construct initiated");
    }

    @PreDestroy
    public void preDestroy(){
         System.out.println("Bean is about to be destroyed, in PreDestroyMethod");
}

  public User(){
      System.out.println("Initializing User");
  }
}

To trigger @PreDestroy, you need to explicitly close the context:

@SpringBootApplication
public class SpringbootApplication{

     public static void main(String[] args){
         ConfigurableApplicationContext context = SpringApplication.run(SpringbootApplication.class, args);
        context.close();
     }
}

6. What is Dependency Injection?

Dependency Injection (DI) is a design pattern that makes your class independent of its concrete dependencies - instead, dependencies are injected from outside.

The Problem Without DI

public class User{

      Order order = new Order(); // Tightly coupled!
     
      public User(){
          System.out.println("Initializing User");
      }
}

This creates two serious problems:

Problem 1: Tight Coupling

If Order changes to an interface with multiple implementations(OnlineOrder, OfflineOrder), the User class must also change. Classes become tightly coupled.

Problem 2: Violates Dependency Inversion Principle (SOLID)

The D in SOLID says: depend on abstractions, not concrete implementations.

// Breaks DIP
public class User {
    Order order = new OnlineOrder(); // depends on concrete class

// Follows DIP
public class User {
    Order order;

   public User(Order orderObj){
       this.order = orderObj; // injected from outside
   }
}

The Solution: Dependency Injection via Spring

@Component
public class User{

   @Autowired
   Order order;
 }

@Component
public class Order{}

@Autowired tells Spring: "Find the appropriate bean for this type and inject it here".

7. Types of Dependency Injection

Spring supports three types of dependency injection:

1. Field Injection

Dependency is set directly on the field using reflection.

@Component
public class User{
 
    @Autowired
    Order order;

    public User(){
        System.out.println("User Initialized");
    }
}

Advantage:

  • Very simple and easy to use

Disadvantage:

  • Cannot use with final (immutable) fields

  • Prone to NullPointerException if you instantiate the class manually (e.g., in unit tests)

  • Setting mock dependencies during unit testing requires reflection - cumbersome

//NPE risk: if you do this outside Spring context
User userObj = new User();
userObj.process(); // order is null here --- NullPointerException!

2. Setter Injection

Dependency is set via a setter method annotated with @Autowired.

@Component
public class User{

   public Order order;

   public User(){
       System.out.println("User initialized");
   }

   @Autowired
   public void setOrderDependency(Order order){
        this.order = order;
   }
}

Advantages:

  • Dependency can be changed after object creation

  • Easy to pass mock objects in tests

Disadvantages:

  • Field cannot be marked final (can't make it immutable)

  • Object initialization and dependency injection are separated - hurts readability

Dependency is resolved at the time of object creation - passed through the constructor.

@Component
public class User{
  
    Order order;

    @Autowired
    public User(Order order){
       this.order = order;
       System.out.println("User initialized");
    }
}

Spring 4.3+ tip: When only one constructor exists, @Autowired is optional.

@Component
public class User{

     Order order;
   
    public User(Order order){ // @Autowired not needed(single constructor)
          this.order = order;
          System.out.println("User initialized");
     }
}

When multiple constructors exist, you must annotate the one Spring should use:

@Component
public class User{
 
    Order order;
    Invoice invoice;

    public User(Order order){
       this.order = order;
       System.out.println("User initialized with only Order");
    }

    @Autowired // Spring uses this one
    public User(Invoice invoice){
         this.invoice = invoice;
        System.out.println("User initialized with only Invoice");
   }
}

Constructor injection is the Spring team's recommended approach, and for very good reasons:

  • Reason 1: Guaranteed Full Initialization

All mandatory dependencies are created at the time of object initialization - your object is 100% ready to use. No runtime NPEs from missing dependencies.

  • Reason 2: Immutability - Use final Fields

Constructor injection is the only way to use final (immutable) fields:

// Works with constructor injection
@Component
public class User{
  
    public final Order order;

    @Autowired
    public User(Order order){
        this.order = order;
        System.out.println("User initialized");
    }
}

// Does NOT work with field injection
@Component
public class User{

    @Autowired
    public final Order order; // compilation error!
}
  • Reason 3: Fail Fast

If a required dependency is missing, the application fails at startup (compile time detection), not silently at runtime when it's too late:

@Component
public class User{

    Order order;

   public User(Order order){
        this.order = order;
    }

   @PostConstruct
   public void init(){
        System.out.println(order == null); // Always false with constructor injection
 }
}

If Order bean is missing -> app fails to start with a clear error, rather than an NPE somewhere deep in runtime execution.

  • Reason 4: Unit Testing is Easy

No reflection tricks needed. Just pass a mock object directly through the constructor:

class UserTest{

    private Order orderMockObj;
    private User user;

    @BeforeEach
    public void setup(){
        this.orderMockObj = Mockito.mock(Order.class);
        this.user = new User(orderMockObj); // clean, simple!
    }
}

Compare to field injection where you need @InjectMocks and Mockito's reflection-based injection - much more complex.

9. Common Issues & How to Fix Them

  • Issue 1: Circular Dependency

This happens when two beans depend on each other, forming a cycle:

@Component
public class Order{
   @Autowired
   Invoice invoice; // Order needs Invoice
 }

@Component
public class Invoice{
   @Autowired
   Order order; // Invoice needs order  --- Cycle!
}

This results in:

APPLICATION FAILED TO START

The dependencies of some of the beans in the application context form a cycle:
----------
|         |
|       Invoice
|         |
|       Order
|---------|

Solutions:

Solution 1: Refactor - Remove the Cycle

Extract shared logic into a third class. Both Order and Invoice depend on it, but not on each other.

Solution 2: Use @Lazy on @Autowired

Spring creates a proxy bean instead of the real bean immediately, breaking the cycle:

@Component
public class Order{
   
    @Lazy
    @Autowired
    Invoice invoice;

    public Order(){
        System.out.println("Order initialized");
    }
}

Solution 3: Use @PostConstruct

Set the dependency after construction using a post-construct method:

@Component
public class Order{

    @Autowired
    Invoice invoice;

    @PostConstruct
    public void initialize(){
           invoice.setOrder(this); // Set back-reference after construction
 }
}

Issue 2: Unsatisfied Dependency

Occurs when Spring can't figure out which implementation to inject for an interface:

public interface Order{}

@Component
public class OnlineOrder implements Order{}

@Component
public class OfflineOrder implements Order{}

@Component
public class User{
    @Autowired
     Order order; // which one? Spring is confused -> ApplicationFailed!
}

Error: UnsatisfiedDependencyException: Error creating bean with name 'User'

Solutions:

Solution 1: @Primary Annotation

Mark one implementation as the default:

@Primary
@Component
public class OnlineOrder implements Order{}

Solution 2: @Qualifier Annotation

Be explicit about which implementation you want:

@Component
public class User{

    @Qualifier("offlineOrderName")
    @Autowired
    Order order;
}

@Component
@Qualifier("onlineOrderName")
public class OfflineOrder implements Order{}

10. Summary

Here's a quick recap of everything covered:

  • Bean - A Java object managed by the Spring IOC container

  • @Component - Auto-creates bean using convention; needs no-arg or injectable constructor

  • @Bean - Explicit bean definition with full control over construction

  • @ComponentScan - Tells Spring where to look for Component classes

  • Eager - Singleton beans created at startup (default behavior)

  • Lazy - Beans created when first requested; use Lazy

  • Bean Lifecycle - Start -> Construct -> Inject -> @PostConstruct -> Use -> @PreDestroy -> Destroy

  • DI - Pattern to decouple classes by injecting dependencies externally

  • Field Injection - Simple but not testable; no immutability; not recommended

  • Setter Injection - Flexible but no immutability; dependencies can be missed

  • Constructor Injection - Recommended - immutable, fail-fast, testable

  • Circular Dependency - Refactor, or use @Lazy / @PostConstruct to break cycles

  • Unsatisfied Dependency - Use @Primary or @Qualifier to resolve ambiguity

Conclusion

Spring's IOC container and dependency injection are the beating heart of any Spring Boot application. Once you deeply understand:

  • How beans are created and discovered

  • When they come alive (eager vs lazy)

  • The full lifecycle from construction to destruction

  • The pros and cons of each injection style

  • And how to handle common pitfalls

You'll write Spring Boot code that is clean, testable, and production-ready.

Happy coding! If you found this helpful, drop a reaction and share it with fellow Spring learners.