Have you ever been puzzled while writing Spring Boot, thinking “I added @Scope("prototype"), but the same instance keeps coming back every time”? Bean scopes tend to be used as singleton without much thought, but understanding them properly broadens your design options. In this article, we’ll go through the behavior of the five standard scopes and the pitfalls of injecting prototype beans into singletons.

What Are Bean Scopes?

A Bean scope defines the lifespan and sharing unit of Bean instances managed by the Spring container.

The default is singleton, where only one instance is created across the entire container. The reason singleton is the standard is that it’s memory-efficient, and stateless service layers have no problem being shared. Conversely, if you want to hold state, you need to consider other scopes.

Scopes are specified with the @Scope annotation. For Bean basics, also see What is @Component.

The Five Standard Scopes

Scope Quick Reference

Summarizing purpose, lifespan, and typical use cases:

ScopeLifespanNumber of InstancesMain Use CaseproxyMode Needed
singletonEntire container1Stateless Service / RepositoryNot needed
prototypePer retrievalOne per retrievalShort-lived processing objects with stateRequired when injecting into singleton
requestHTTP requestOne per requestPer-request traceId / contextRequired (TARGET_CLASS)
sessionHTTP sessionOne per sessionLogged-in user info, cartRequired (TARGET_CLASS)
applicationServletContext1Configuration shared across the appUsually not needed

Selection guideline: When in doubt, start with singleton. Only consider short-lived scopes when you need to hold state, and when injecting into a singleton, use ObjectProvider or proxyMode = TARGET_CLASS.

Spring provides the following five standard scopes:

  • singleton: One per container. Ideal for stateless service layers
  • prototype: New instance per retrieval. For short-lived objects with state
  • request: Per HTTP request (Web environment only)
  • session: Per HTTP session. For login user info, etc.
  • application: Per ServletContext. For configuration shared across all requests

Declarations look like this:

@Component
@Scope("prototype")
public class OrderProcessor {
    // New instance per retrieval
}

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
    private String traceId;
    // Holds per-request information
}

Using constants like ConfigurableBeanFactory.SCOPE_PROTOTYPE instead of string literals prevents typos.

Choosing the Right proxyMode

When injecting short-lived scoped Beans like request or prototype into a singleton, you need to specify proxyMode. This is because the reference injected at singleton initialization gets fixed and won’t switch per request.

proxyMode has two options:

  • TARGET_CLASS: CGLIB-based subclass proxy. Works without an interface
  • INTERFACES: JDK dynamic proxy. Requires an interface implementation

Generally, TARGET_CLASS works fine, but note that you can’t create subclasses for final classes.

The Pitfall of Injecting Prototype into Singleton

This is the most common stumbling block. Look at the following code:

@Component
@Scope("prototype")
public class Task {
    private final long id = System.nanoTime();
    public long getId() { return id; }
}

@Service
public class TaskRunner {
    private final Task task;

    public TaskRunner(Task task) {
        this.task = task;
    }

    public void run() {
        System.out.println("task id = " + task.getId());
    }
}

Even if you call TaskRunner twice, the value of task.getId() is the same. Since TaskRunner itself is a singleton, the Task reference received in the constructor gets fixed. The “new instance every time” behavior of prototype applies to the retrieval moment — once injected and held, it stays as a single instance.

The recommended approach since Spring 4.3 is ObjectProvider. By calling getObject() at the necessary timing, you can retrieve a new instance each time.

@Service
public class TaskRunner {
    private final ObjectProvider<Task> taskProvider;

    public TaskRunner(ObjectProvider<Task> taskProvider) {
        this.taskProvider = taskProvider;
    }

    public void run() {
        Task task = taskProvider.getObject();
        System.out.println("task id = " + task.getId());
    }
}

Null-safe APIs like getIfAvailable() and getIfUnique() are also available, making it easy to use.

Workaround 2: jakarta.inject.Provider

The JSR-330 standard Provider does the same thing. This is useful when considering portability to other DI containers.

import jakarta.inject.Provider;

@Service
public class TaskRunner {
    private final Provider<Task> taskProvider;

    public TaskRunner(Provider<Task> taskProvider) {
        this.taskProvider = taskProvider;
    }

    public void run() {
        System.out.println("task id = " + taskProvider.get().getId());
    }
}

Using this requires adding a dependency on jakarta.inject:jakarta.inject-api.

Workaround 3: @Lookup Method Injection

By annotating abstract or concrete methods with @Lookup, Spring generates a subclass and injects the method implementation.

@Service
public abstract class TaskRunner {
    @Lookup
    protected abstract Task createTask();

    public void run() {
        Task task = createTask();
        System.out.println("task id = " + task.getId());
    }
}

Since CGLIB subclass generation is a prerequisite, it can’t be used with final classes. Keep it in mind as an option for when ObjectProvider can’t be used.

Workaround 4: Retrieving Directly from ApplicationContext

As a last resort, you can use ApplicationContext.getBean.

@Service
public class TaskRunner {
    private final ApplicationContext context;

    public TaskRunner(ApplicationContext context) {
        this.context = context;
    }

    public void run() {
        Task task = context.getBean(Task.class);
        System.out.println("task id = " + task.getId());
    }
}

This tightly couples the logic to the Spring API, making testing harder. Choose this only when other approaches aren’t available.

Prerequisites for Using request / session Scopes

request and session are only valid in a Web environment (Spring MVC). When injecting into a singleton controller or service, proxyMode = ScopedProxyMode.TARGET_CLASS is required, as mentioned earlier.

@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class LoginUser implements Serializable {
    private String userId;
    // Information held within the session
}

Making session-scoped Beans Serializable prepares them for session replication and file persistence. For testing, RequestContextHolder setup is required, so leveraging slice tests like @WebMvcTest makes things easier.

Watch Out for Anti-Patterns

Once you start being conscious of scopes, you might be tempted to over-engineer. The following patterns are best avoided:

  • Singletons that hold state: Thread safety easily breaks down. Don’t keep mutable state in fields
  • Everything as prototype: Over-engineering. Passing state via arguments is often simpler
  • Stuffing a lot of business logic into request/session: Makes testing difficult. Keep them as information holders only

When in doubt, it’s safer to first consider a design of singleton + passing state via arguments. For the Bean lifecycle itself, Bean lifecycle covers it in detail.

Summary

The basic Bean scope is singleton. When state management becomes necessary, use prototype, request, or session as appropriate. When injecting short-lived Beans into a singleton, ObjectProvider is the most natural first choice. When using Web-dependent scopes, don’t forget to specify proxyMode and set up the context during testing.

Understanding scopes lets you explain “why this design.” Even if singleton works fine for you day-to-day, knowing the options expands your design toolkit.

If you want to dive deeper into Spring Boot’s DI/Bean area, check out these articles: