DEV Community

Ankit Verma
Ankit Verma

Posted on

Bean scopes (singleton/prototype/request/session) + singleton thread-safety gotcha

Every object the container builds has to answer two quiet questions: how many copies of it should exist, and how long should each one live? One shared copy for the whole application? A fresh one every time someone asks? One per web request? The answer to those two questions is the bean's scope.

You usually meet scope the day shared state misbehaves. A field on a @Service holds a value for one user and somehow shows up for another. Or you expected a brand-new object each time and kept getting the same one back. Both are scope surprises — and both have the same root cause once you can see it.

So this article walks the scopes Spring gives you, starting with the default you have been leaning on without ever naming it. Then it covers the two traps scope sets — including the one that causes real, hard-to-find production bugs.

The default you have already been using

Every bean in the previous articles was a singleton: the container builds exactly one instance, caches it at startup, and hands that same object to everyone who needs it, for the whole life of the application. You never asked for this. It is simply what @Component, @Service, and a bare @Bean give you.

@Service
class OrderService { }   // one instance, shared everywhere
Enter fullscreen mode Exit fullscreen mode

Ask the container for it twice and you get the identical object back:

OrderService a = ctx.getBean(OrderService.class);
OrderService b = ctx.getBean(OrderService.class);
// a == b  → true, same instance
Enter fullscreen mode Exit fullscreen mode

One word of caution on the name. A Spring singleton means one instance per container — not the classic JVM-wide singleton from design-pattern books. Spin up a second container and you get a second instance. Within one application that distinction rarely bites, but it is why "singleton" here is a scope, not a guarantee about the whole JVM.

Asking for a fresh one each time: prototype

Sometimes one shared instance is exactly wrong. You want a short-lived, stateful helper — a builder you fill in, use once, and throw away. For that, Spring offers the prototype scope: a brand-new instance every single time the bean is requested.

@Component
@Scope("prototype")
class ReportBuilder { }   // a new one on every request
Enter fullscreen mode Exit fullscreen mode

Now each getBean hands back a different object:

ReportBuilder a = ctx.getBean(ReportBuilder.class);
ReportBuilder b = ctx.getBean(ReportBuilder.class);
// a != b  → two separate instances
Enter fullscreen mode Exit fullscreen mode

Two more things change with prototype. The container builds it on demand, when you ask — not eagerly at startup like a singleton. And, as the lifecycle article noted, Spring runs a prototype's setup but then forgets it: it never calls the destroy stage. The container builds a prototype, hands it over, and walks away.

Why the default is a singleton — and the trap hiding inside it

One shared instance is cheap and fast, which is why it is the default. But "shared" is the dangerous word, and it leads straight to the single most common Spring bug.

A web application handles many requests at the same time, each on its own thread. Every one of those threads calls into the same singleton instance. So any mutable field on a singleton is shared across all of them at once:

@Service
class CartService {
    private int itemCount;          // ONE field, shared by every thread

    void addItem() { itemCount++; } // two threads here = lost updates
}
Enter fullscreen mode Exit fullscreen mode

This looks fine in testing, where one request runs at a time. In production it corrupts data. Two users add items concurrently, both threads read itemCount, both increment, both write back — and one update vanishes. Worse, one user's count is now visible to another, because there was only ever one field.

The fix is a rule, not a trick: keep singletons stateless. Don't store per-call or per-user data in fields. Let that data live in method parameters, local variables, and return values — those sit on each thread's own stack, so they are never shared.

@Service
class CartService {
    int withItemAdded(int count) {   // state passed in and out, never stored
        return count + 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

Note what is not a problem: fields that are set once at construction and never change — your injected dependencies, anything final. Those are shared too, but since nobody mutates them, no thread can corrupt them. The bug is specifically mutable shared state. Immutable shared state is exactly what a singleton is good at.

Scopes that follow the web request

Sometimes you genuinely need per-user or per-request state, and stuffing it through method parameters everywhere gets ugly. For that, Spring adds two web-aware scopes.

A request-scoped bean lives for exactly one HTTP request: the container builds a fresh one when the request arrives and discards it when the response is sent. A session-scoped bean lives for one user's session, spanning their many requests until the session ends.

@Component
@RequestScope
class RequestContext {
    private String traceId;   // safe: each request gets its own instance
}
Enter fullscreen mode Exit fullscreen mode

Because every request gets its own instance, that mutable traceId field is now perfectly safe — the sharing that doomed the singleton simply isn't there. This is the right home for "data that belongs to this one request."

But it raises an awkward question. A singleton is built once, at startup. A request-scoped bean does not exist yet at startup — no request is in flight. So how can you inject a request-scoped bean into a singleton, when the thing you want to inject won't exist until much later, and then keeps being replaced?

The scoped-proxy fix

This is where the proxy from the first article comes back to do real work. When you inject a shorter-lived bean into a longer-lived one, Spring does not inject the real bean — it injects a proxy: a same-shaped stand-in that, on every method call, looks up the correct instance for the current request and forwards to it.

@RequestScope switches this proxy on by default, so the injection just works:

@Service
class AuditService {
    private final RequestContext context;   // really a proxy, injected once

    AuditService(RequestContext context) {
        this.context = context;
    }

    void record(String event) {
        context.setTraceId(event);   // resolves to THIS request's instance
    }
}
Enter fullscreen mode Exit fullscreen mode

The singleton AuditService is wired exactly once at startup, holding the proxy forever. But each call through that proxy lands on the right per-request RequestContext. Without the proxy you would be stuck: either a startup failure because no request exists to inject, or one shared instance that defeats the entire point of request scope. The proxy bridges the lifespans.

The prototype-in-singleton trap

Now the trap that catches almost everyone, because it fails silently rather than loudly. Inject a prototype into a singleton the obvious way, and you do not get a fresh prototype per call:

@Service
class InvoiceService {
    private final ReportBuilder builder;   // injected ONCE, frozen forever

    InvoiceService(ReportBuilder builder) {
        this.builder = builder;
    }

    void generate() {
        builder.reset();   // same instance every invoice — not what you wanted
    }
}
Enter fullscreen mode Exit fullscreen mode

You chose prototype precisely so each invoice would get its own ReportBuilder. Instead you got exactly one, captured at startup, reused for the life of the app. The reason is simple once it clicks: injection is a one-time event for a singleton. Spring builds InvoiceService once, so it resolves and injects its dependencies once. The prototype scope only promises a new instance per request to the container — and that request happened a single time, at wiring.

The fix is to ask the container for a new one at the moment you need it, instead of capturing one at startup. ObjectProvider is the clean way to do exactly that:

@Service
class InvoiceService {
    private final ObjectProvider<ReportBuilder> builders;

    InvoiceService(ObjectProvider<ReportBuilder> builders) {
        this.builders = builders;
    }

    void generate() {
        ReportBuilder builder = builders.getObject();   // a fresh prototype each call
    }
}
Enter fullscreen mode Exit fullscreen mode

Each getObject() is a new request to the container, so each returns a new prototype. (A scoped proxy on the prototype achieves the same thing transparently, the way @RequestScope did — but ObjectProvider makes the "give me a fresh one now" intent obvious at the call site.)

Putting it together

A scope answers two questions about a bean: how many instances exist, and how long each lives. Singleton — one per container, the default — is built once and shared by everyone. Prototype is a new instance on every request, built on demand, and never destroyed by the container. Request and session scope tie a bean's life to one HTTP request or one user's session.

Both traps in this article come from mixing lifespans. The thread-safety bug is one singleton shared across many threads at once, so any mutable field is a race — fixed by keeping singletons stateless. The stale-injection bug is a short-lived bean injected once into a long-lived one, so it freezes at startup — fixed by the scoped proxy, or by pulling a fresh instance from an ObjectProvider when you actually need it.

Hold on to the one idea underneath both: for a singleton, wiring happens exactly once. Everything that surprises you about scope follows from remembering when injection actually runs.

Top comments (0)