Skip to main content
spring internals

Prototype Scope and Its Destruction Trap

7 min read Chapter 71 of 78

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.