A prototype-scoped bean injected into a singleton is created exactly once. That is not a corner case, it is how Spring's dependency injection works by design, and it catches people who assume @Scope("prototype") means "new instance whenever someone asks."
Here is the setup that trips people up:
@Component
@Scope("prototype")
class TokenGenerator {
private final String seed = UUID.randomUUID().toString();
String next() {
return seed + "-" + System.nanoTime();
}
}
@Service
class ReportService {
private final TokenGenerator gen;
ReportService(TokenGenerator gen) {
// resolved ONCE, right here, when the container wires ReportService
this.gen = gen;
}
String generate() {
return gen.next();
}
}
ReportService is a singleton. Spring builds it once at startup, and to build it, it has to resolve the TokenGenerator constructor argument. That resolution happens exactly one time. The prototype scope on TokenGenerator never gets a second chance to do its job, because nothing ever asks the container for another one. ReportService just holds onto the first instance it got, for the rest of the application's life.
If TokenGenerator were stateless, nobody would ever notice. The bug shows up the moment the prototype bean carries state that is supposed to reset per use, a running total, a per-request seed, a cache that should not outlive one call. Then every "fresh" instance is actually the same object, and callers start stepping on each other's state.
Fix 1: ApplicationContext.getBean() — it works, but it is ugly
The instinctive patch is to stop injecting the bean directly and instead ask the container for it on demand:
@Service
class ReportService {
@Autowired
private ApplicationContext ctx;
String generate() {
TokenGenerator gen = ctx.getBean(TokenGenerator.class);
return gen.next();
}
}
This does fix the bug. Every call to generate() pulls a brand-new TokenGenerator from the container, because getBean() triggers scope resolution every time it runs, not just at wiring time.
The problem is what it costs you. ReportService now holds a live reference to the whole ApplicationContext, which means it can reach any bean in the application, not just the one it needs. Unit testing gets worse too: instead of mocking one collaborator, you have to mock the entire context or stand up a real one. And the lookup is stringly typed by class, which means a typo or a refactor that renames the bean fails at runtime, not at compile time. It works. It also does not belong in application code that has any other option.
Fix 2: an abstract @Lookup method — cleaner, still Spring-flavored
Spring has a purpose-built mechanism for exactly this case:
@Service
abstract class ReportService {
@Lookup
abstract TokenGenerator gen();
String generate() {
return gen().next();
}
}
@Lookup tells Spring to generate a subclass of ReportService at runtime (via CGLIB) that overrides gen() to fetch a fresh TokenGenerator from the container every time it is called. Your code never touches ApplicationContext directly, never does a stringly-typed getBean() call, and the method signature documents exactly what type it returns.
The catch: ReportService and the gen() method cannot be final, because CGLIB needs to subclass them. Constructor injection for other dependencies still works fine alongside @Lookup, but the class itself has to stay proxyable. It is still coupled to Spring, just through an annotation instead of an API call, and it depends on a runtime-generated subclass existing, which occasionally surprises people debugging stack traces for the first time.
Fix 3: inject ObjectProvider — the one to actually reach for
The cleanest option skips both the container reference and the CGLIB proxy:
@Service
class ReportService {
private final ObjectProvider<TokenGenerator> genProvider;
ReportService(ObjectProvider<TokenGenerator> genProvider) {
this.genProvider = genProvider;
}
String generate() {
TokenGenerator gen = genProvider.getObject();
return gen.next();
}
}
ObjectProvider<T> is a plain constructor-injected dependency, no ApplicationContext, no abstract class, no CGLIB subclass. Calling .getObject() resolves a fresh prototype bean at that exact moment, and only at that moment. The rest of ReportService stays a normal, final, easily-mocked class. If you are on jakarta.inject, Provider<T> gives you the same behavior with a one-method interface, if you prefer not to depend on a Spring-specific type at all.
This is also the version that is easiest to test. Mock ObjectProvider<TokenGenerator> to return a stub TokenGenerator from getObject(), and ReportService never needs a real Spring context in the test.
The honest trade-off
None of these three fixes matter if TokenGenerator is stateless. A stateless prototype bean behaves identically to a singleton, so field-injecting it directly is not a bug, it is just an unnecessary scope declaration. Reach for ObjectProvider only when the prototype bean genuinely carries per-call state that would otherwise leak between callers. Adding indirection to fetch a bean that never needed to be re-created is its own kind of mistake, just a quieter one.
There is a real cost either way: ObjectProvider (or @Lookup, or getBean()) adds a layer of indirection that a reader has to understand before they see why the bean is not just constructor-injected like everything else. That indirection earns its keep exactly when the state leak is real, and nowhere else.
Have you shipped the ApplicationContext.getBean() version to production before catching it in review? What made you notice?
Top comments (0)