Bean Scopes
Understand singleton, prototype and request scopes, why singletons must be stateless, and how to use a shorter-lived bean from a longer-lived one.
@Scope("prototype") | @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@RequestScope // proxied by defaultA bean's scope decides how many instances the container creates and how long each lives. Almost every bean in a REST API should be a singleton, which is the default.
| Scope | Instances | Typical use |
|---|---|---|
singleton | One per context | Services, repositories, controllers, clients |
prototype | A new one for every lookup | Stateful helpers that must not be shared |
request | One per HTTP request | Per-request context in web applications |
session | One per HTTP session | Server-side session state (rare in stateless APIs) |
Singletons are shared by every thread
A Spring singleton is not the Gang of Four pattern; it is simply one instance per context. Under load, hundreds of request threads call the same instance at once. A mutable field such as private Order current; on a service is a race condition that shows up as one customer seeing another's data. Keep per-request data in local variables and parameters, make fields final, and use thread-safe types such as ConcurrentHashMap or AtomicLong for anything genuinely shared.
The prototype trap
Declaring a bean with @Scope("prototype") means a new instance each time the container is asked for one. But a singleton is built once, so if it injects a prototype through its constructor, it receives one instance and keeps it forever. The prototype behaves like a singleton and the scope achieved nothing. Inject ObjectProvider<T> and call getObject() whenever you need a fresh instance.
Spring does not manage a prototype after handing it over, so @PreDestroy methods on prototype beans never run. If one holds a resource, the caller must close it.
Request scope and proxies
@RequestScope creates one instance per HTTP request. You can inject it into a singleton because Spring injects a proxy rather than a real instance; each method call on the proxy is routed to the instance for the current request. Calling it from a thread with no request, such as a @Scheduled job or an @Async method, fails with an exception explaining that no thread-bound request was found.
Prefer passing data explicitly through method parameters. Request-scoped beans are handy for cross-cutting values such as a correlation id or the resolved tenant, but they hide a dependency on the web layer inside your services.
Example
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.annotation.RequestScope;
record ImportRequest(List<String> skus) {}
record ImportResult(String requestId, int imported, List<String> warnings) {}
// One instance per HTTP request; the controller receives a proxy.
class ImportWarnings {
private final String requestId = UUID.randomUUID().toString();
private final List<String> warnings = new ArrayList<>();
void add(String warning) {
warnings.add(warning);
}
List<String> all() {
return List.copyOf(warnings);
}
String requestId() {
return requestId;
}
}
public class ProductImportController {
private final ImportWarnings warnings;
ProductImportController(ImportWarnings warnings) {
this.warnings = warnings;
}
("/imports")
ImportResult importProducts( ImportRequest request) {
int imported = 0;
for (String sku : request.skus()) {
if (sku.isBlank()) {
warnings.add("blank SKU skipped");
continue;
}
imported++;
}
return new ImportResult(warnings.requestId(), imported, warnings.all());
}
}
When to use it
- Track down customers seeing each other's data because a service kept the current order in a field.
- Give each file import its own stateful parser while the import service stays a singleton.
- Collect warnings during one HTTP request and return them in that request's response.
More examples
Getting a fresh prototype from a singleton
Had the service injected CsvRowCounter directly, every import would add to the same counters, and two concurrent imports would corrupt each other's numbers. ObjectProvider asks the container each time. In many cases a plain new CsvRowCounter() inside the method is simpler still; use a prototype bean only when the helper itself needs injected dependencies.
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
// Holds state while one CSV file is parsed, so it must not be shared.
@Component
@Scope("prototype")
class CsvRowCounter {
private int rows;
private int rejected;
void accept(String line) {
if (line.split(",").length < 3) {
rejected++;
} else {
rows++;
}
}
String summary() {
return rows + " rows, " + rejected + " rejected";
}
}
@Service
public class CsvImportService {
private final ObjectProvider<CsvRowCounter> counters;
public CsvImportService(ObjectProvider<CsvRowCounter> counters) {
this.counters = counters;
}
public String importFile(Iterable<String> lines) {
CsvRowCounter counter = counters.getObject(); // a new instance on every call
lines.forEach(counter::accept);
return counter.summary();
}
}Fixing shared state in a singleton
Per-call values live in locals, which each thread has its own copy of. The only field is a counter that is meant to be shared, and it uses an atomic type. Bugs from mutable singleton fields rarely appear in development, where you send one request at a time, and appear in production as wrong totals under load.
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;
@Service
public class QuoteService {
// Shared across all requests: safe because AtomicLong is thread-safe.
private final AtomicLong quotesIssued = new AtomicLong();
// Wrong: a field such as "private long lastTotal;" would be overwritten by concurrent requests.
public long quote(long basePence, int quantity) {
long total = basePence * quantity; // local variable: private to this call
if (quantity >= 10) {
total = total * 90 / 100;
}
quotesIssued.incrementAndGet();
return total;
}
public long quotesIssued() {
return quotesIssued.get();
}
}Proving the scopes in a test
Two lookups of a singleton return the same object; two lookups of a prototype return different ones. A test like this is a cheap way to check your understanding, or to guard a bean whose correctness depends on its scope.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
class ScopeTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(ScopeConfig.class);
@Test
void singletonIsSharedAndPrototypeIsNot() {
runner.run(context -> {
assertThat(context.getBean(Shared.class)).isSameAs(context.getBean(Shared.class));
assertThat(context.getBean(Fresh.class)).isNotSameAs(context.getBean(Fresh.class));
});
}
@Configuration(proxyBeanMethods = false)
static class ScopeConfig {
@Bean
Shared shared() {
return new Shared();
}
@Bean
@Scope("prototype")
Fresh fresh() {
return new Fresh();
}
}
static class Shared {}
static class Fresh {}
}
Discussion