“What bean scopes does Spring support, and which is the default?” is a warm-up question, and most candidates answer it correctly. The real test is the follow-up: “So what happens if a singleton service keeps a counter in a field?” or “You inject a prototype bean into a singleton. How many instances do you get?” Those questions separate people who memorized a list from people who understand that a singleton is shared by every request thread at once.
Before you start
You should know how constructor injection works, what a Spring @Service is, and that a servlet container such as Tomcat handles each HTTP request on its own thread (from a pool of 200 by default in Spring Boot). The examples use Spring Boot 3.x.
The short answer
The default scope is singleton: one instance per application context, shared by all threads. Other scopes are prototype (new instance each time the container is asked), and in web applications request, session and application. Singletons should be stateless or hold only thread-safe, effectively immutable state. Injection happens once, so a shorter-lived bean injected into a singleton needs an ObjectProvider or a scoped proxy, otherwise the singleton keeps the first instance forever.
How it works
The container stores singleton instances in a cache after creating them. Every getBean call and every injection point gets the same object. Prototype beans are created on every request to the container and are not tracked afterwards; Spring does not call their destroy callbacks.
@Component
class SingletonCounter { // default scope: singleton
private int count; // shared by every thread
int next() { return ++count; } // not atomic: read, add, write
}
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
class ReportBuilder { // new instance per container request
private final List<String> lines = new ArrayList<>();
void add(String line) { lines.add(line); }
String build() { return String.join("\n", lines); }
}++count compiles to a read, an increment and a write. Two Tomcat threads can both read 41 and both write 42. With 200 threads under load you lose increments, and nothing throws.
Web scopes are stored per request or per HTTP session. Because a singleton is created at startup, when no request exists, Spring cannot inject a real request-scoped object into it. A scoped proxy solves this: Spring injects a CGLIB proxy, and each method call on the proxy looks up the instance for the current request.
@Component
@RequestScope // shorthand for @Scope(value = "request", proxyMode = TARGET_CLASS)
class RequestContext {
private String tenantId;
String tenantId() { return tenantId; }
void tenantId(String id) { this.tenantId = id; }
}Step-by-step walkthrough
Step 1: Spot state in a singleton
Look at every field of a @Service, @Component or @Controller. Final references to other beans are fine. Configuration values are fine. Anything that changes per request is a bug.
@Service
class PriceService {
private final TaxClient taxClient; // fine: dependency
private final BigDecimal margin; // fine: fixed config
private Customer currentCustomer; // bug: per-request data in shared object
PriceService(TaxClient taxClient, @Value("${pricing.margin}") BigDecimal margin) {
this.taxClient = taxClient;
this.margin = margin;
}
}Step 2: Move per-request data into parameters
The simplest fix is not a scope at all. Pass the data as method arguments so it lives on each thread’s stack.
BigDecimal quote(Customer customer, Product product) {
BigDecimal tax = taxClient.taxFor(customer.country(), product);
return product.price().add(tax).multiply(margin);
}If you genuinely need a shared counter, use a thread-safe type such as AtomicLong or, better, a Micrometer counter.
Step 3: Get a fresh prototype from a singleton
Injecting ReportBuilder into a singleton constructor resolves it once. To get a new one each time, inject an ObjectProvider and ask it per call.
@Service
class ReportService {
private final ObjectProvider<ReportBuilder> builders;
ReportService(ObjectProvider<ReportBuilder> builders) {
this.builders = builders;
}
String monthly(List<Order> orders) {
ReportBuilder builder = builders.getObject(); // new instance every call
orders.forEach(o -> builder.add(o.summary()));
return builder.build();
}
}Alternatives are @Lookup methods or simply new ReportBuilder() if the class has no dependencies. A prototype that needs no injection rarely needs to be a bean.
Step 4: Use request scope through a proxy
A filter fills the request-scoped bean, and a singleton reads it. The proxy routes each call to the current request’s instance.
@Component
class TenantFilter extends OncePerRequestFilter {
private final RequestContext context; // proxy
TenantFilter(RequestContext context) { this.context = context; }
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
context.tenantId(req.getHeader("X-Tenant"));
chain.doFilter(req, res);
}
}Calling the proxy outside a request, for example from an @Async method or a scheduled job, throws IllegalStateException: No thread-bound request found.
Worked scenario
An invoice service formats amounts with a shared formatter to “save allocations”:
@Service
class InvoiceFormatter {
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
String header(Invoice invoice) {
return "Invoice " + invoice.id() + " dated " + dateFormat.format(invoice.date());
}
}Tests pass. In production, under load, some invoices show dates such as 2026-10-31 for an October 6 invoice, and occasionally the thread throws ArrayIndexOutOfBoundsException from inside SimpleDateFormat. The class keeps an internal Calendar that it mutates during format, so concurrent calls corrupt each other.
The fix is to use an immutable, thread-safe formatter from java.time:
@Service
class InvoiceFormatter {
private static final DateTimeFormatter DATE = DateTimeFormatter.ISO_LOCAL_DATE;
String header(Invoice invoice) {
return "Invoice " + invoice.id() + " dated " + DATE.format(invoice.date()); // date is a LocalDate
}
}Changing the bean to prototype scope would not help, because the controller that depends on it is itself a singleton and would hold one instance forever. The lesson is to make singletons safe for concurrent use rather than trying to scope your way out.
Common mistake
- “Prototype scope makes it thread safe.” Only if every caller fetches a new instance. Constructor injection into a singleton gives one instance for the singleton’s lifetime.
- “Spring synchronizes singleton methods.” It does not. Spring manages lifecycle, not concurrency.
- “Singleton means the GoF singleton pattern.” It means one instance per container. Two application contexts have two instances, and you can still call
newyourself. - Forgetting the proxy for request or session scope. A plain
@Scope("request")withoutproxyModefails at startup with a scope-not-active error when injected into a singleton. - Expecting
@PreDestroyon prototypes. Spring does not manage prototype destruction.
Verify the behavior
This test shows that a prototype injected into a singleton is shared, while ObjectProvider gives fresh instances.
@SpringJUnitConfig({ReportBuilder.class, ScopeCheck.class})
class ScopeTest {
@Component
static class ScopeCheck {
final ReportBuilder injectedOnce;
final ObjectProvider<ReportBuilder> provider;
ScopeCheck(ReportBuilder injectedOnce, ObjectProvider<ReportBuilder> provider) {
this.injectedOnce = injectedOnce;
this.provider = provider;
}
}
@Autowired ScopeCheck check;
@Autowired ApplicationContext ctx;
@Test
void injectedPrototypeIsFixedButProviderIsFresh() {
assertThat(ctx.getBean(ScopeCheck.class).injectedOnce).isSameAs(check.injectedOnce);
assertThat(check.provider.getObject()).isNotSameAs(check.provider.getObject());
}
}For the counter race, a test that runs next() from 50 threads 1,000 times each will usually end below 50,000; switching the field to AtomicInteger makes it exact.
Follow-up questions
What is the difference between singleton and application scope? Application scope is one instance per ServletContext and is exposed as a servlet context attribute; singleton is one per Spring container.
When is a singleton created? Eagerly at startup by default, unless marked @Lazy or spring.main.lazy-initialization=true is set.
How do you inject an HTTP session-scoped shopping cart into a controller? Annotate the cart @SessionScope, which includes a target-class proxy, and inject it normally.
Are controllers singletons? Yes. That is why controller fields must not hold request data.
Interview exercise
A colleague adds private List<String> errors = new ArrayList<>(); to a singleton ImportValidator, clears it at the start of validate(file), and returns it at the end. Users occasionally see errors from someone else’s upload. Explain why and give two fixes.
Answer and reasoning
The validator is a singleton shared by all request threads. Two uploads running at once share one list: thread A clears it, thread B adds errors, and A returns a list containing B’s errors. ArrayList is not thread safe either, so it can also corrupt internally. Fix one: make the list a local variable inside validate and return it, which keeps the bean stateless (the preferred answer). Fix two: return an immutable result object built per call. Making the bean prototype only works if every caller obtains a new instance per validation, which is easy to get wrong. Synchronizing the method would work but serializes all uploads for no reason.