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:
| Scope | Lifespan | Number of Instances | Main Use Case | proxyMode Needed |
|---|---|---|---|---|
| singleton | Entire container | 1 | Stateless Service / Repository | Not needed |
| prototype | Per retrieval | One per retrieval | Short-lived processing objects with state | Required when injecting into singleton |
| request | HTTP request | One per request | Per-request traceId / context | Required (TARGET_CLASS) |
| session | HTTP session | One per session | Logged-in user info, cart | Required (TARGET_CLASS) |
| application | ServletContext | 1 | Configuration shared across the app | Usually 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 interfaceINTERFACES: 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.
Workaround 1: ObjectProvider (Recommended)
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.
Related Articles
If you want to dive deeper into Spring Boot’s DI/Bean area, check out these articles:
- How to Loosely Couple Modules with Spring Boot ApplicationEvent — Event-driven implementation patterns that, alongside Bean scopes, form a pillar of loosely-coupled design
- How to Achieve a Modular Monolith with Spring Modulith — A reference for extending Bean design to module boundaries
- Implementing Spring Boot’s GlobalExceptionHandler for Production — Exception handling patterns often combined with request/session scopes