Prototype Scope and Its Destruction Trap
Prototype Scope and Its Destruction Trap
Prototype scope sounds simple: new instance per injection. But there is a trap hidden in the lifecycle contract that catches experienced engineers. Spring creates prototype beans. Spring does not destroy them. @PreDestroy is never called. If your prototype holds a database connection, a file handle, or a thread pool, those resources leak until the JVM shuts down or the heap fills up.
How Prototype Creation Works
When AbstractBeanFactory.doGetBean() encounters a prototype-scoped bean definition, it takes a different path than singleton:
else if (mbd.isPrototype()) {
Object prototypeInstance = null;
try {
beforePrototypeCreation(beanName);
prototypeInstance = createBean(beanName, mbd, args);
} finally {
afterPrototypeCreation(beanName);
}
beanInstance = getObjectForBeanInstance(prototypeInstance, name, beanName, mbd);
}
There is no getSingleton() call. No singletonObjects map. No caching. createBean() runs the full bean creation pipeline: instantiation, property population, BeanPostProcessor callbacks, @PostConstruct. The result is handed to the caller and the container moves on.
beforePrototypeCreation() and afterPrototypeCreation() manage a ThreadLocal set of bean names currently being created. This exists solely to detect circular references among prototypes, which Spring cannot resolve (unlike singleton circular references handled by the three-level cache in CH2).
The Destruction Contract
Singleton beans are registered for destruction. When the ApplicationContext closes, DefaultSingletonBeanRegistry.destroySingletons() iterates over all registered disposable beans and calls their destruction methods: @PreDestroy, DisposableBean.destroy(), or custom destroyMethod.
Prototype beans are not registered. The relevant code is in AbstractBeanFactory:
// AbstractBeanFactory.registerDisposableBeanIfNecessary
protected void registerDisposableBeanIfNecessary(
String beanName, Object bean, RootBeanDefinition mbd) {
if (!mbd.isPrototype() && requiresDestruction(bean, mbd)) {
if (mbd.isSingleton()) {
registerDisposableBean(beanName,
new DisposableBeanAdapter(bean, beanName, mbd, ...));
} else {
// Custom scope: register destruction callback with the scope
Scope scope = this.scopes.get(mbd.getScope());
scope.registerDestructionCallback(beanName,
new DisposableBeanAdapter(bean, beanName, mbd, ...));
}
}
// Prototype: nothing. No registration. No callback.
}
The if (!mbd.isPrototype()) guard is explicit. Spring’s documentation states this clearly, but the behavior is counterintuitive. If @PostConstruct is called, you expect @PreDestroy to be called. For prototypes, that expectation is wrong.
The Resource Leak
In the SaaS backend, consider a ReportGenerator that opens a temporary file for buffering large tenant reports:
// BROKEN: Prototype with resources and @PreDestroy
@Component
@Scope("prototype")
public class ReportGenerator {
private final BufferedWriter writer;
private final Path tempFile;
@PostConstruct
void init() throws IOException {
this.tempFile = Files.createTempFile("report-", ".csv");
this.writer = Files.newBufferedWriter(tempFile);
// @PostConstruct runs. Resources acquired.
}
public void addSection(String section) throws IOException {
writer.write(section);
}
public Path finalize() throws IOException {
writer.flush();
writer.close();
return tempFile;
}
@PreDestroy
void cleanup() throws IOException {
// NEVER CALLED. Spring does not track this bean.
writer.close();
Files.deleteIfExists(tempFile);
}
}
Every time ReportGenerator is injected or obtained via getBean(), a new temp file is created. If the caller forgets to call finalize(), the file handle leaks. @PreDestroy will not save you. The temp files accumulate until the disk fills up.
The same applies to any resource: JDBC connections, HTTP clients, thread pools, native memory allocations. If the prototype acquires it, the prototype must release it explicitly.
Why Spring Made This Choice
The reasoning is straightforward. Prototype scope means “the container creates it, the caller owns it.” The container does not hold a reference to the instance after creation. It cannot. If it did, prototypes would be indistinguishable from singletons in memory behavior. Every prototype instance would be retained for the lifetime of the context, defeating the purpose.
Consider a prototype ReportGenerator used in a batch job that processes 10,000 tenants. If Spring tracked all 10,000 instances, they would all be held in memory until context shutdown. That is not prototype semantics. That is a memory leak by design.
The contract is: Spring manages creation. The caller manages destruction.
Pattern 1: Explicit Lifecycle Management
The simplest approach. The caller creates the bean, uses it, and cleans it up:
// CORRECT: Caller manages prototype lifecycle
@Service
public class ReportService {
private final ApplicationContext context;
public Path generateReport(String tenantId, ReportRequest request) {
ReportGenerator generator = context.getBean(ReportGenerator.class);
try {
generator.addSection(buildHeader(tenantId));
for (ReportSection section : request.getSections()) {
generator.addSection(renderSection(section));
}
return generator.finalize();
} catch (IOException e) {
generator.forceCleanup(); // Manual cleanup on error
throw new ReportGenerationException(tenantId, e);
}
}
}
This works but pushes lifecycle management to every call site. If there are twenty places that use ReportGenerator, you need twenty try-finally blocks.
Pattern 2: AutoCloseable and Try-With-Resources
Make the prototype implement AutoCloseable:
// CORRECT: AutoCloseable prototype
@Component
@Scope("prototype")
public class ReportGenerator implements AutoCloseable {
private BufferedWriter writer;
private Path tempFile;
@PostConstruct
void init() throws IOException {
this.tempFile = Files.createTempFile("report-", ".csv");
this.writer = Files.newBufferedWriter(tempFile);
}
public void addSection(String section) throws IOException {
writer.write(section);
}
public Path complete() throws IOException {
writer.flush();
writer.close();
this.writer = null; // Prevent double close
return tempFile;
}
@Override
public void close() throws IOException {
if (writer != null) {
writer.close();
}
if (tempFile != null) {
Files.deleteIfExists(tempFile);
}
}
}
Callers use try-with-resources:
@Service
public class ReportService {
private final ObjectProvider<ReportGenerator> generatorProvider;
public Path generateReport(String tenantId, ReportRequest request) {
try (ReportGenerator generator = generatorProvider.getObject()) {
generator.addSection(buildHeader(tenantId));
for (ReportSection section : request.getSections()) {
generator.addSection(renderSection(section));
}
return generator.complete();
} catch (IOException e) {
throw new ReportGenerationException(tenantId, e);
}
}
}
ObjectProvider<ReportGenerator> is the preferred way to obtain prototypes. It avoids direct ApplicationContext dependency and makes the intent clear: “I need a new instance each time.”
Pattern 3: DestroyAwareBean with BeanPostProcessor
For cases where you want centralized destruction tracking, implement a custom BeanPostProcessor that tracks prototypes:
@Component
public class PrototypeDestructionPostProcessor
implements BeanPostProcessor, DisposableBean {
private final List<DisposableBean> trackedBeans =
Collections.synchronizedList(new ArrayList<>());
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof DisposableBean disposable) {
BeanDefinition bd = /* look up definition */;
if (bd != null && bd.isPrototype()) {
trackedBeans.add(disposable);
}
}
return bean;
}
@Override
public void destroy() {
for (DisposableBean bean : trackedBeans) {
try {
bean.destroy();
} catch (Exception e) {
// Log and continue
}
}
trackedBeans.clear();
}
}
This approach has a significant downside: it holds references to all prototype instances, preventing garbage collection until context shutdown. Use it only when prototype instances are few and their lifecycle must be tied to the application context.
Prototype Injection into Singletons
There is another trap. When a prototype is injected into a singleton via constructor injection, the prototype is resolved once at singleton creation time. All subsequent uses of the singleton use the same prototype instance:
// BROKEN: Prototype injected into singleton, resolved once
@Service
public class OrderService {
private final ReportGenerator reportGenerator;
// This is ONE instance, injected at startup.
// Every call to createOrderReport() uses the SAME generator.
// Shared mutable state across all requests.
public OrderService(ReportGenerator reportGenerator) {
this.reportGenerator = reportGenerator;
}
public Path createOrderReport(Order order) {
reportGenerator.addSection(order.toString());
// Appending to the same writer across all requests
return reportGenerator.complete();
}
}
The fix is ObjectProvider:
// CORRECT: ObjectProvider creates new prototype per use
@Service
public class OrderService {
private final ObjectProvider<ReportGenerator> reportGeneratorProvider;
public Path createOrderReport(Order order) {
try (ReportGenerator generator = reportGeneratorProvider.getObject()) {
generator.addSection(order.toString());
return generator.complete();
} catch (IOException e) {
throw new OrderReportException(order.getId(), e);
}
}
}
ObjectProvider.getObject() calls getBean() internally, which creates a new prototype instance each time. The singleton holds the provider, not the prototype.
Debugging Prototype Issues
To verify that a bean is actually prototype-scoped and that separate instances are created:
@Service
public class PrototypeDiagnostics {
private final ObjectProvider<ReportGenerator> provider;
@PostConstruct
void verify() {
ReportGenerator a = provider.getObject();
ReportGenerator b = provider.getObject();
System.out.println("Same instance: " + (a == b)); // false
System.out.println("Class: " + a.getClass().getName()); // No CGLIB suffix
// Prototypes are not proxied unless explicitly configured
}
}
If a == b returns true, the bean is not actually prototype-scoped. Check the @Scope annotation and ensure component scanning picks it up.
The prototype scope contract is simple but unforgiving: Spring gives you the object; you give it back to the garbage collector when you are done. If the object holds resources, you close them. No framework magic will do it for you.