Skip to main content

Command Palette

Search for a command to run...

Understanding Spring Bean Scopes: Singleton, Prototype, Request & Session

Updated
9 min read
Understanding Spring Bean Scopes: Singleton, Prototype, Request & Session
N
Java & Spring Boot learner | Writing beginner-friendly technical articles | Exploring backend development

A comprehensive guide to Spring IoC bean Scopes with real-world examples, code walkthroughs, and gotchas you must know.

1. What Are Bean Scopes

In Spring, the scope of a bean defines its lifecycle - how long a bean instance lives and how many instances of that bean exist at any point in time.

Spring provides four primary scopes:

                  Scope
                  /   \
           Singleton  Request
               |         \
            Prototype   Session
  • For Singleton scope 1 per IOC container instances are created and Initialized eagerly at startup.

  • For Prototype scope one new instances are created and initialization is lazy.

  • For Request scope 1 per HTTP request instances are created and initialization is lazy.

  • For Session scope 1 per HTTP session instances are created and initialization is lazy.

2. Singleton Scope

Singleton is the default scope in Spring. Only one instance of the bean is created per IoC (Inversion of Control) container, and its eagerly initialized - meaning the object is created when the application starts up, not when it's first requested.

Key Properties:

  • Default scope - no annotation needed, but you can use @Scope("singleton")

  • Only 1 instance per IOC container

  • Eagerly initialized at application startup

  • The same object is shared across all components that inject it

Code Example

TestController1.java

@RestController
public class TestController1{
  
     @Autowired
     User user;

     public TestController1(){
         System.out.println("TestController1 instance initialization");
    }

    @PostConstruct
    public void init(){
        System.out.println("TestController1 object hashCode: " + this.hashCode());
   }

    @GetMapping(path = "/fetchUser")
     public ResponseEntity<String> getUserDetails(){
          System.out.println("fetchUser api invoked");
          return ResponseEntity.status(HttpStatus.OK).body("");
      }
}

User.java (Singleton Bean)

@Component
public class User{

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

     @PostConstruct
     public void init(){
         System.out.println("User object hashCode: " + this.hashCode());
     }
}

Console Output:

TestController1 instance initialization
User initialization
User object hashCode: 1140202235
TestController1 object hashCode: 1046302571 User object hashCode: 1140202235
TestController2 instance initialization
TestController2 object hashCode: 1525241607 User object hashCode: 1140202235

Notice something important here: both TestController1 and TestController2 print the same User object hashCode (1140402235). This confirms that only one instance of User exists across the entire application - the essence of Singleton scope.

3. Prototype Scope

In Prototype scope, a new object is created every single time the bean is requested from the container. Unlike Singleton, it is lazily initialized - the object is only created when it is actually needed.

Key Properties

  • Annotated with @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)

  • New instance created every time the bean is injected or requested

  • Lazily initialized

  • Each consumer gets its own private instance

Code Example:

TestController1.java

@RestController
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@RequestMapping(value = "/api/")
public class TestController1 {

    @Autowired
    User user;

    @Autowired
    Student student;

    public TestController1() {
        System.out.println("TestController1 instance initialization");
    }

    @PostConstruct
    public void init() {
        System.out.println("TestController1 object hashCode: " + this.hashCode()
            + " User object hashCode: " + user.hashCode()
            + " Student object hashCode: " + student.hashCode());
    }

    @GetMapping(path = "/fetchUser")
    public ResponseEntity<String> getUserDetails() {
        System.out.println("fetchUser api invoked");
        return ResponseEntity.status(HttpStatus.OK).body("");
    }
}

Student.java(Prototype Bean)

@Component
public class Student {

    @Autowired
    User user;

    public Student() {
        System.out.println("Student instance initialization");
    }

    @PostConstruct
    public void init() {
        System.out.println("Student object hashCode: " + this.hashCode()
            + " User object hashCode: " + user.hashCode());
    }
}

User.java(Prototype Bean)

@Component
@Scope("prototype")
public class User {

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

    @PostConstruct
    public void init() {
        System.out.println("User object hashCode: " + this.hashCode());
    }
}

Console Output on Startup

Student instance initialization
User initialization
User object hashCode: 1510009630
Student object hashCode: 2092450685  User object hashCode: 1510009630

Console Output After HTTP Request to /api/fetchUser

TestController1 instance initialization
User initialization
User object hashCode: 1984730322
TestController1 object hashCode: 1786739207  User object hashCode: 1984730322  Student object hashCode: 2092450685
fetchUser api invoked

Notice Here:

  • The Student bean was created at startup (because Student is not prototype - just User is in this scenario)

  • When the HTTP request hits, a brand new User with a different hashCode (1984730322) is created for TestController1

  • Each request produces a fresh instance

4. Request Scope

In Request scope, a new bean instance is created for every HTTP request. Once the request completes, the bean is discarded. It is lazily initialized - created only when a request comes in.

Key Properties

  • Annotated with @Scope("request") or @RequestScope

  • New object per HTTP request

  • Lazily initialized

  • Perfect for holding request-specific data

Code Example

TestController1.java

@RestController
@Scope("request")
@RequestMapping(value = "/api/")
public class TestController1 {

    @Autowired
    User user;

    @Autowired
    Student student;

    public TestController1() {
        System.out.println("TestController1 instance initialization");
    }

    @PostConstruct
    public void init() {
        System.out.println("TestController1 object hashCode: " + this.hashCode()
            + " User object hashCode: " + user.hashCode()
            + " Student object hashCode: " + student.hashCode());
    }

    @GetMapping(path = "/fetchUser")
    public ResponseEntity<String> getUserDetails() {
        System.out.println("fetchUser api invoked");
        return ResponseEntity.status(HttpStatus.OK).body("");
    }
}

User.java(Request-Scoped Bean)

@Component
@Scope("request")
public class User {

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

    @PostConstruct
    public void init() {
        System.out.println("User object hashCode: " + this.hashCode());
    }
}

Console Output - First Request

TestController1 instance initialization
User initialization
User object hashCode: 39793904
Student instance initialization
Student object hashCode: 275139209  User object hashCode: 39793904
TestController1 object hashCode: 898967761  User object hashCode: 39793904  Student object hashCode: 275139209
fetchUser api invoked

Console Output - Second Request

TestController1 instance initialization
User initialization
User object hashCode: 1227388929
Student instance initialization
Student object hashCode: 1206886228  User object hashCode: 1227388929
TestController1 object hashCode: 1137709937  User object hashCode: 1227388929  Student object hashCode: 1206886228
fetchUser api invoked

Each HTTP request produces completely new instances of TestController1, User, and Student - with entirely different hashCodes. This proves the request-scoped bean lifecycle is tied strictly to the HTTP request.

5. The Classic Gotcha

A "gotcha" is developer slang for " a mistake that's easy to make and hard to spit until it blows up."

What happens when you inject a Request-scoped bean into a Singleton-scoped bean?

This is one of the most common Spring pitfalls. Consider this scenario:

TestController1.java - Singleton scope

@RestController
@Scope("singleton")
@RequestMapping(value = "/api/")
public class TestController1 {

    @Autowired
    User user;  // User is Request-scoped!

    // ...
}

User.java - Request scope

@Component
@Scope("request")
public class User {
    // ...
}

What happens?

org.springframework.beans.factory.UnsatisfiedDependencyException:
Error creating bean with name 'testController1':
Unsatisfied dependency expressed through field 'user'

The application fails to start with an error!

Why does this happen?

Spring created the Request scope bean only when there is an active HTTP request present. Since TestController1 is Singleton, it is eagerly initialized at startup - and at that point, there is no active HTTP request in the current thread. So Spring simply cannot create the User bean, and the dependency injection fails.

Think of it this way:

A Singleton bean lives for the entire application lifetime. A Request-scoped bean only lives during an HTTP request. You can't inject something short-lived into something long-lived directly.

Fixing the Gotcha with Scoped Proxy

Solution: proxyMode = ScopedProxyMode.TARGET_CLASS

Spring provides an elegant solution: Scoped Proxy. Instead of injecting the real User bean, Spring injects a proxy object. When a method is called on this proxy, it delegates to the actual User bean that exists in the current HTTP request context.

User.java - Fixed with Scoped Proxy

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class User {

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

    @PostConstruct
    public void init() {
        System.out.println("User object hashCode: " + this.hashCode());
    }

    public void dummyMethod() {
        // placeholder method
    }
}

Console Output After Fix

TestController1 instance initialization
TestController1 object hashCode: 1356419559  User object hashCode: 1159352444

At startup, the User hashCode shown is that of the proxy object (not a real User bean yet). Notice "User initialization" is not printed at startup anymore.

After HTTP Request:

fetchUser api invoked
User initialization
User object hashCode: 1078757370

Now "User Initialization" prints after the API is invoked - because the real User bean is created lazily when the HTTP request arrives.

This is the power of ScopedProxyMode.TARGET_CLASS - it allows long-lived beans to safely hold references to short-lived beans.

6. Session Scope

Session scope creates a new bean instance per HTTP session. The session is created when a user first accesses any endpoint and remains active until it expires or is explicitly invalidated.

Key Properties

  • Annotated with Scope("session")

  • New object per HTTP session (not per request)

  • Lazily initialized

  • Perfect for holding user-specific state across multiple requests

  • Remains active until session expires or is invalidated

Code Example:

TestController1.java

@RestController
@Scope(value = "session")
@RequestMapping(value = "/api/")
public class TestController1 {

    @Autowired
    User user;

    public TestController1() {
        System.out.println("TestController1 instance initialization");
    }

    @PostConstruct
    public void init() {
        System.out.println("TestController1 object hashCode: " + this.hashCode()
            + " User object hashCode: " + user.hashCode());
    }

    @GetMapping(path = "/fetchUser")
    public ResponseEntity<String> getUserDetails() {
        System.out.println("fetchUser api invoked");
        return ResponseEntity.status(HttpStatus.OK).body("");
    }

    @GetMapping(path = "/logout")
    public ResponseEntity<String> getUserDetails(HttpServletRequest request) {
        System.out.println("end the session");
        HttpSession session = request.getSession();
        session.invalidate();
        return ResponseEntity.status(HttpStatus.OK).body("");
    }
}

User.java(Session Bean)

@Component
public class User {

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

    @PostConstruct
    public void init() {
        System.out.println("User object hashCode: " + this.hashCode());
    }

    public void dummyMethod() {}
}

Lifecycle Demo

Request1 -> /api/fetchUser

User initialization
User object hashCode: 254812629
TestController1 object hashCode: 1476570007  User object hashCode: 254812629
fetchUser api invoked

Request2 -> /api/fetchUser (same session)

TestController1 object hashCode: 1476570007  User object hashCode: 254812629
fetchUser api invoked

Same objects - same session!

Request3 -> /api/logout

end the session

Request4 -> /api/fetchUser (new session after logout)

TestController1 instance initialization
User object hashCode: 254812629
TestController1 object hashCode: cb6839a  User object hashCode: 254812629
fetchUser api invoked

New TestController1 instance, but User is Singleton (same hashCode) - fresh session-scoped controller, shared User.

7. Key Takeaways

  1. Singleton is the default — one instance per IoC container, eagerly initialized at startup.

  2. Prototype creates a new instance every time — useful when you need stateful, independent objects.

  3. Request scope creates a fresh bean per HTTP request — ideal for request-specific processing.

  4. Session scope creates a bean per user session — great for maintaining user state across requests.

  5. Never inject a shorter-lived bean directly into a longer-lived bean. For example, injecting a Request-scoped bean into a Singleton will cause an UnsatisfiedDependencyException.

  6. Use proxyMode = ScopedProxyMode.TARGET_CLASS to safely inject shorter-lived (Request/Session) beans into longer-lived (Singleton) beans. The proxy handles the delegation at runtime.

Conclusion

Understanding bean scopes is fundamental to building correct, efficient Spring Boot applications. Getting it wrong can lead to subtle bugs — like sharing state across requests when you shouldn't, or application crashes due to scope mismatch.

The golden rule: match the bean's scope to its actual lifecycle requirements, and always use a scoped proxy when mixing scopes.

Happy coding!