Ch. 8 · Spring Boot

Spring Bean Scopes and Shared Mutable State

Spring bean scopes explained: why singletons must be stateless, injecting prototype or request beans into singletons, and scoped proxies.

~6 min readbeginnerupdated Oct 6, 2026

“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); }
}
java

++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; }
}
java

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;
    }
}
java

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);
}
java

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();
    }
}
java

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);
    }
}
java

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());
    }
}
java

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
    }
}
java

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 new yourself.
  • Forgetting the proxy for request or session scope. A plain @Scope("request") without proxyMode fails at startup with a scope-not-active error when injected into a singleton.
  • Expecting @PreDestroy on 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());
    }
}
java

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.

Continue learning

More in Spring Boot

esc