1. The destroy phase as part of the application
The init phase answers the question “when is the bean ready,” while the destroy phase answers the mirror question: who neatly finishes what the container started at startup.
If you have mostly written console applications so far, it is easy to fall into a trap: it seems like the program just “ends,” and that is enough. But a Spring application is not only a set of objects, it is also a container that manages their lifecycle. And this “lifecycle” has a finale: you need to shut down what was started correctly. Otherwise you get the classic effect of “everything seemed to work, but why is the file empty?”, “why was the last line not written at the end?”, “why were the resources not released?”. And yes, sometimes this shows up at the most inconvenient moments — for example, when you first run the application not from the IDE, but somewhere in a pipeline or under a different environment.
The destroy phase is especially important for infrastructure beans that hold resources: open files, buffers, temporary directories, connections (in our course there is no database, but the principle is the same), cache, prepared indexes, and any other things that need to be “cleaned up” on shutdown. The good news: Spring can do this on its own — if you have honestly described the destroy logic and closed the context. The bad news: if the context is not closed, the destroy phase may never happen. The container is not telepathic: it will not guess that you are done unless you say “we are closing” yourself.
2. When Spring calls destroy logic
For the destroy phase to run, the container must receive a clear signal: “the context is finishing work.” In our non-web application ContextFlow this usually looks like this: we created an AnnotationConfigApplicationContext, ran the scenario, and then closed the context. The most convenient and “rock-solid” way is try-with-resources, because it closes the context even if an exception occurs inside (and exceptions, as we know, love arriving uninvited).
Here is the minimal template you should keep as a reflex:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class App {
public static void main(String[] args) {
// Important: try-with-resources guarantees context closure (and therefore destroy logic invocation)
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// The context is already up here: beans are created and ready to work
System.out.println("Context started"); // Context started
} // <- ctx.close() will be called automatically here and the destroy phase will start
}
}
And here it is important to feel the moment: destroy callbacks are invoked not because main() ended, but because we closed the ApplicationContext. main() can end “in the style of: fell and burned,” the JVM can be killed, the process can be stopped — but the correct scenario that Spring relies on is exactly closing the context.
Sometimes you want to say: “fine, I do not want try-with-resources, I will just create the context and let it somehow handle itself.” That approach usually ends with “somehow” turning into “not at all.” If for some reason try-with-resources is inconvenient for you, you can explicitly call close, but that is already manual mode.
Another option is to register a shutdown hook so that the context closes when the JVM shuts down cleanly. This is useful if your application “lives” longer and you do not wrap everything in one try (for example, you have a long loop, input waiting, and so on).
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class App {
public static void main(String[] args) {
// We create the context manually (and must take care of proper shutdown ourselves)
var ctx = new AnnotationConfigApplicationContext(AppConfig.class);
// Register a shutdown hook: during a normal JVM shutdown Spring will try to close the context
ctx.registerShutdownHook(); // asked the JVM to close the context on shutdown
// The application keeps running: the context remains open until the process ends
System.out.println("App is running"); // App is running
}
}
But there is a nuance here: a shutdown hook is “try to close it properly,” not “guaranteed.” If the process is killed abruptly, if the JVM crashes, if the power is pulled (not that this is a typical scenario for a learning project, but life is rich) — the destroy phase may not run. That is why in a learning application, and in honest code for beginners in general, try-with-resources remains the most predictable approach: you clearly see where the life of the context begins and ends.
For a mental model, it is useful to imagine it like this:
created the context
↓
the context created beans
↓
the application executed the scenario
↓
the context was closed
↓
the context invokes destroy logic for beans
3. Three ways to describe the destroy phase: @PreDestroy, DisposableBean, destroyMethod
Now that it is clear when the destroy phase actually begins, let us figure out how we can describe it. Spring gives three classic options here, which correspond exactly to the same three styles we saw in the init phase: annotation, interface, and configuration method. These are not “three different kinds of magic,” but three different ways to tell the container: “this is where I want code to run on shutdown.”
To make orientation easier, keep a short comparison table — no religious wars, only engineering consequences:
| Way | Where the destroy logic lives | What is good about it | What is the cost |
|---|---|---|---|
| @PreDestroy | inside the class | very readable, visible next to the code | jakarta.annotation-api is required; the class is still annotated |
| DisposableBean | inside the class (through an interface) | maximally explicit, without string method names | tight coupling to the Spring API |
| destroyMethod | in configuration | the class remains “plain Java,” Spring-specific details stay in the config | a string method name appears in the config (it can be mistyped) |
In a teaching style it is usually nice to follow this rule: if the class is ours and it really “lives” as a bean — @PreDestroy is almost always the most readable. If you want to keep the class free from Spring coupling (for example, it is an infrastructure piece that could theoretically be used outside the container) — then destroyMethod in @Bean looks more honest. We also need to know DisposableBean, because it appears in legacy and infrastructure code, but making it the default is usually not desirable.
4. @PreDestroy: the most readable option for “our” classes
The @PreDestroy annotation is good because the reader sees it right in the class and understands: “aha, there is shutdown logic here, it is managed by the container.” This is especially pleasant in a learning project, where we want the dependency “the container manages the object lifecycle” to be visible to the eye rather than guessed. You already used @PostConstruct in the init phase — @PreDestroy feels like its logical conclusion: first we prepared things, then we carefully rolled everything back.
A technical detail worth stating once: @PreDestroy lives in the jakarta.annotation package. That means if you want to use it in a plain Spring project, you need the jakarta.annotation:jakarta.annotation-api dependency. In our course it already appears when we enable @PostConstruct, so it usually does not add any “new pain” — just remember that it is not “built into Java by default.”
A mini-example of a bean that keeps a small cache. It is almost like NotificationTemplateCatalog in ContextFlow: it loads/stores templates and can clear its internal state on shutdown.
import jakarta.annotation.PreDestroy;
import java.util.HashMap;
import java.util.Map;
public class NotificationTemplateCatalog {
// Internal cache: this is state that is logically worth cleaning up on shutdown
private final Map<String, String> templates = new HashMap<>();
@PreDestroy
void clearCache() {
// Spring will call this method when the context is closed (destroy phase)
templates.clear();
System.out.println("templates cleared"); // templates cleared
}
}
Pay attention to the “human” logic of the method: it does nothing heroic. It does not try to “resume a business process,” does not call another service, does not build a report. It does exactly what an object that stores internal state should do: clear it and finish predictably.
Another important benefit of @PreDestroy: it pairs nicely with the idea that “the class speaks for itself.” Open NotificationTemplateCatalog — and you immediately see that it has a “beginning” and an “end” in container terms. For a beginner, this is a direct path to proper container thinking: you stop looking at a bean as “just an object” and start seeing it as an object whose lifecycle is defined by the container.
5. DisposableBean: it works honestly, but adds Spring coupling
DisposableBean is a Spring interface, and it solves the same task as @PreDestroy: it gives the container an explicit callback on destruction. Its strength is that it is maximally “programmer-friendly”: no string magic, no external configuration — there is a destroy() method, and its invocation at the right moment is clear even without annotations. If you have read code where a class has implements DisposableBean, you will almost certainly not miss what it wants to do.
But this honesty has a price: the class becomes Spring-dependent. Now you cannot pretend that it is “just a Java class.” That may be fine for infrastructure, but for the business layer and most of “our” application classes, it is usually better to keep loose coupling to the framework.
Here is the minimal example:
import org.springframework.beans.factory.DisposableBean;
public class ReportOutputManager implements DisposableBean {
@Override
public void destroy() {
// Spring will call this method when the context is closed, because the class implements DisposableBean
System.out.println("output manager closed"); // output manager closed
}
}
From a behavior point of view everything is great: when the context is closed, Spring will call destroy(). From a design point of view, we get a “technical marker” right in the class signature. Sometimes that is appropriate, sometimes you want it a bit cleaner.
There is one more methodological nuance: once you start injecting Spring interfaces into application classes, a beginner may draw the wrong conclusion that “Spring interfaces are the normal everyday style.” And then the favorite attraction begins: *Aware in business services, service locators, and other fun stuff. We do not want that, so DisposableBean is important to know, but it is important to use it consciously — not out of the habit of “an interface is more reliable than an annotation.”
6. destroyMethod in @Bean: when the class should not know about Spring
The third path is to describe the destroy phase at the configuration level, that is, where you create the bean through @Bean. This is very similar to what we did in the init phase: the class stays “clean,” and Spring-specific details live in @Configuration. This approach is especially good for infrastructure components, where you want to show clearly: “the container is responsible for the lifecycle, and the class simply knows how to close itself.”
In ContextFlow this fits ReportOutputManager very well. It is infrastructure: it prepares the build/... directory, may keep some writers or buffers, and on shutdown it should close neatly. And it is very pleasant when ReportingService does not know at all that there is a close() method — it just receives a ready component.
Configuration example:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class ReportingConfig {
@Bean(destroyMethod = "close")
ReportOutputManager reportOutputManager() {
// The bean is created by the container, and the destroy method ("close") is bound through configuration
return new ReportOutputManager();
}
}
And the class itself remains ordinary:
public class ReportOutputManager {
public void close() {
// This method will be called by Spring when the context is closed (because of destroyMethod = "close")
System.out.println("closed"); // closed
}
}
This approach has simple logic: lifecycle contracts live next to wiring. If you open the configuration, you see not only “how to create the object,” but also “how to shut it down correctly.” It is similar to how you open and close a resource in try-with-resources — only here the container does it.
And yes, this is the string "close". It can be mistyped. But as a teaching example, that is fine: we are learning to keep wiring and lifecycle together, and the IDE usually suggests method names. The main thing is not to turn configuration into a “king class” where all application logic lives. Configuration describes assembly and lifecycle, not business processes.
7. What to do in a destroy method
A destroy method is not a place where you “just manage to finish everything at the last second.” It is more like the final cleanup. Good destroy methods fit actions that release resources and bring the object into a correct finished state: close a writer, flush buffers, finish work with a temporary directory, release local resources. If a destroy method tries to “write one more business event” or “send one more notification,” you create an unpleasant architectural land mine for yourself: shutting down the context should be predictable and fast.
Another practical thought: destroy methods should be safe to call more than once. In real life, shutdown can happen in several stages, repeated closing can happen, errors can happen. You do not want the second call to close() to fail with an “already closed” exception and ruin application shutdown. That is why a simple “protection against repeated closing” is often used.
Mini-example with a flag:
import java.util.concurrent.atomic.AtomicBoolean;
public class ReportOutputManager {
// Flag: have we already closed or not (needed so that close() is idempotent)
private final AtomicBoolean closed = new AtomicBoolean(false);
public void close() {
// compareAndSet(false, true) — only the first call performs the "closing"
if (closed.compareAndSet(false, true)) {
System.out.println("close() executed"); // close() executed
}
// Repeated calls simply do nothing — that is normal for shutdown logic
}
}
There is no complicated magic here: the first call really performs the closing, and subsequent calls are quietly ignored. For a learning project this is an excellent style: you reduce the chance of a “farewell exception” that appears only on shutdown and is unpleasant to debug.
Also, do not throw exceptions from a destroy method as if you were firing a cannon at sparrows. First, the application is shutting down anyway — this is no longer the point where you can “save the world.” Second, an exception on shutdown easily turns into noise that masks the real cause of the problem. If something can go wrong while closing a resource, it is better to handle it carefully and print a clear diagnostic message (in the course — at least System.out.println, in real life — logging).
8. A mini-demo on ContextFlow
Let us connect the topic with our end-to-end project. In ContextFlow ReportOutputManager is an infrastructure component. Ideally ReportingService simply uses it without thinking that somewhere it needs to manually call prepare() or close(). And this is exactly where container thinking starts to feel practical: the container takes over the lifecycle, and the services stay focused on business scenarios.
Let us show the minimal linkage: ReportOutputManager as a bean with destroyMethod, and App, which closes the context via try-with-resources. The whole demo is based on the fact that closing the context automatically invokes the destroy logic.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class AppConfig {
@Bean(destroyMethod = "close")
ReportOutputManager reportOutputManager() {
// The container will create the bean, and when the context is closed it will call close()
return new ReportOutputManager();
}
}
public class ReportOutputManager {
public void close() {
// This line is a marker that the destroy phase really ran
System.out.println("ReportOutputManager closed"); // ReportOutputManager closed
}
}
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class App {
public static void main(String[] args) {
// try-with-resources: we guarantee that ctx.close() will be called (and therefore destroy methods too)
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// The application's "useful work" happens here
System.out.println("Doing work..."); // Doing work...
}
// After exiting the try, Spring closes the context and invokes destroy logic for beans
}
}
If you run this, the sequence will be completely honest: first you will see "Doing work...", and then, when exiting the try, the context will close and print "ReportOutputManager closed". And that is exactly what “not magic, but discipline” means: you gave the container a clear description of lifecycle behavior and shut the context down correctly.
Important point: if you remove try-with-resources and simply create the context “hoping for the best,” then when main() ends the destroy method may not run. Sometimes it seems like “it worked anyway,” but that is more of an accident. In a normal application, lifecycle should be under control, otherwise it turns into Schrödinger’s cat: either the resources were closed or they were not.
9. Typical mistakes in the destroy phase
Mistake #1: the context is not closed, and you expect @PreDestroy.
This is the most common scenario: you added @PreDestroy, wrote a nice "closed", ran the application... and nothing was printed. The problem is usually not the annotation and not Spring, but the fact that you did not close the ApplicationContext. For a learning application, almost always choose try-with-resources around AnnotationConfigApplicationContext, so that closure happens reliably.
Mistake #2: trying to turn the destroy method into a “last chance to run business logic.”
Sometimes you want to “finish the report,” “send a notification,” or “recalculate statistics” on shutdown. In practice this quickly turns into a fragile system: shutdown may be triggered by an error, dependencies may be unavailable during the process, and you end up with a cascade of exceptions at the most inconvenient moment. The destroy phase should release resources and finish prepared state, not launch business scenarios.
Mistake #3: using DisposableBean as a thoughtless standard for all classes.
DisposableBean works, but it “threads” Spring into your class. For infrastructure this is acceptable, but if you start doing this everywhere, the code becomes more framework-dependent than necessary, and it becomes harder to perceive as ordinary Java code. For most of our classes, @PreDestroy is more readable, and if you want to move Spring-specific details outward, destroyMethod in @Bean is better.
Mistake #4: the destroy method throws an exception and turns shutdown into a drama.
Destroy logic runs while the application is closing, and if it starts “shooting” exceptions, you get lots of noise and little benefit. Keep destroy methods short, make them safe to call repeatedly, and handle possible closing errors carefully. Often it is more useful to print a clear message and continue shutdown than to bring down the closing process because of a secondary problem.
Mistake #5: expecting lifecycle behavior from objects created with new.
@PreDestroy, DisposableBean, destroyMethod — all of this works only for objects managed by the container. If you created an object manually with new, Spring does not know anything about it and cannot properly “say goodbye” to it. This sounds obvious, but in practice beginners often mix up a “regular object” and a “bean,” and then wonder why the container did not call the needed method.
GO TO FULL VERSION