JAVA + SPRING AI — ADVANCED
Bean Scopes, Lifecycle & Circular Dependencies in Spring
The Intermediate level covered how a bean gets created and wired. Here’s what happens around that moment — how many instances actually exist, what runs right after creation and right before destruction, and what happens when two beans each need the other to exist first.
The Quick Answer
By default, every Spring bean is a singleton — the container creates exactly one instance and hands that same shared object to everything that depends on it. That matters because concurrent requests all touch that one instance at the same time, so mutable state stored on it needs real thought about thread-safety. Beans also have a lifecycle: @PostConstruct runs once a bean is fully built and wired, and @PreDestroyruns before it’s removed. And when two beans each depend on the other, Spring can detect that circular dependency at startup — @Lazy is one real, documented way to break the cycle.
Singleton vs. Prototype
Spring’s bean scopes control how many instances of a bean the container creates:
- Singleton (the default). One instance per Spring container, shared by every part of the application that depends on it. This is the scope you get unless you explicitly ask for something else.
- Prototype. A brand-new instance is created every single time the bean is requested from the container. Nothing is shared between callers.
Spring’s own reference documentation gives a direct rule of thumb for choosing between them: use the prototype scope for stateful beans, and the singleton scope for stateless ones. The reasoning follows naturally from what “singleton” means here — since every request, on every thread, is handed the exact same object, any mutable field on that object is effectively shared, mutable state across your whole running application. Two requests arriving at the same moment can read and write that field concurrently, which is a textbook data race unless you’ve deliberately made the bean thread-safe (immutable fields set once at construction time, as covered in the Intermediate level, sidestep this entirely — which is a large part of why constructor injection paired with singleton beans works so well by default).
Bean Lifecycle: Init and Destroy
A bean’s life doesn’t end the moment its constructor returns. Spring gives you hooks at two more points: right after the bean is fully built and every dependency is injected, and right before the container discards it (typically at application shutdown). The current idiomatic way to hook into both:
@Service
public class ConnectionPoolService {
private final DataSourceProperties properties;
public ConnectionPoolService(DataSourceProperties properties) {
this.properties = properties;
}
@PostConstruct
public void warmUp() {
// Runs once, after dependencies are injected —
// safe to use "properties" here.
System.out.println("Warming up connection pool...");
}
@PreDestroy
public void shutdown() {
// Runs once, before the bean is discarded.
System.out.println("Closing connection pool...");
}
}@PostConstruct and @PreDestroyaren’t the only way to do this — Spring has offered lifecycle hooks since well before those annotations existed, and still supports the older alternatives: implementing the InitializingBean and DisposableBean callback interfaces, or declaring plain custom init() / destroy()methods and pointing Spring at them via configuration. The annotation-based approach is the one you’ll see by far the most often in current code, since it avoids coupling your class to a Spring-specific interface at all.
Circular Dependencies
A circular dependency happens when, say, ServiceA needs ServiceB injected into its constructor, and ServiceB’s constructor needs ServiceA right back. With constructor injection, this is a genuine problem, not just an inconvenience: to fully construct ServiceA, the container needs a complete ServiceB first, but building that complete ServiceB needs a complete ServiceA — neither one can finish first. Spring detects this at application startup and fails fast with a BeanCurrentlyInCreationException rather than hanging or silently producing a broken object.
The most direct mitigation is one Spring documents itself: @Lazyon one of the two injection points. Instead of forcing the real, fully-built bean to exist immediately, a lazy-resolution proxy is injected in its place — a stand-in that only triggers the real bean’s creation the first time one of its methods actually gets called. That breaks the deadlock: both constructors can now finish, because neither one is blocking on the other actually being finished yet.
@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(@Lazy ServiceB serviceB) {
this.serviceB = serviceB; // a proxy, resolved on first real use
}
}
@Service
public class ServiceB {
private final ServiceA serviceA;
public ServiceB(ServiceA serviceA) {
this.serviceA = serviceA;
}
}It’s worth being honest about what @Lazy is: a real, working escape hatch, not a design goal. A circular dependency between two services is usually a sign that some piece of shared responsibility belongs in a third class both of them can depend on instead — untangling the design is generally the better fix. @Lazyexists for the cases where that isn’t practical right now.
Where This Goes Next
Everything covered across all three levels of this topic — annotations that register a bean, constructor injection, singleton scope, lifecycle callbacks — isn’t refresher trivia you’ll leave behind once Spring AI shows up later in this pillar. A Spring AI ChatClient, a VectorStore for RAG, or a tool-calling service are, underneath, just more Spring beans — built, scoped, and wired by the exact same ApplicationContext mechanics covered here.
Fun Fact
Spring’s singleton scope is deliberately not the same thing as the classic “Singleton” design pattern from the Gang of Four book. A GoF singleton hardcodes uniqueness process-wide via a private constructor and a static accessor. A Spring singleton bean is only unique per container— nothing stops you from running two separate Spring containers in the same JVM, each with its own, separate “singleton” instance of the very same class.
Test Yourself
What is the default bean scope in Spring?
Why is storing mutable, per-request state directly on a singleton bean's fields risky?
When does @PostConstruct run on a bean?
What is one real way Spring lets you work around a circular dependency between two beans?
What do beans further along in this pillar (like a Spring AI ChatClient) have in common with the beans covered in this topic?