A deep dive into how Spring manages object creation, dependency injection, singleton guarantees, and circular dependency resolution — without any magic hand-waving.
- Normal Java — No Spring
- The Spring Way — Manual Beans, No @Autowired
- The @Configuration Class
- Bootstrapping the Spring Context
- What Actually Happens Internally
- What Happens If You Remove @Configuration
- Where Is
newActually Used? - Bean Lifecycle — Internal Flow
- Why This Is Powerful
- Circular Dependency Resolution
- Key Internal Classes to Know
- Interview Cheat Sheet
In vanilla Java, you are responsible for everything.
class Engine {
public void start() {
System.out.println("Engine started");
}
}
class Car {
private Engine engine;
public Car() {
this.engine = new Engine(); // manual dependency wiring
}
public void drive() {
engine.start();
System.out.println("Car running");
}
}
public class Main {
public static void main(String[] args) {
Car car = new Car(); // YOU create the object
car.drive();
}
}Characteristics:
| Concern | Who Handles It |
|---|---|
| Object creation | You |
| Dependency wiring | You |
| Lifecycle management | You |
| Singleton guarantee | You |
Every new Car() call produces a brand-new instance. There is no shared state, no lifecycle hooks, and no proxy support.
We hand off object creation responsibility to Spring — but keep things explicit with constructor injection and no field injection magic.
class Engine {
public void start() {
System.out.println("Engine started");
}
}
class Car {
private final Engine engine;
public Car(Engine engine) { // dependency injected via constructor
this.engine = engine;
}
public void drive() {
engine.start();
System.out.println("Car running");
}
}Car no longer knows how to build its own Engine. It declares what it needs and Spring provides it.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class AppConfig {
@Bean
public Engine engine() {
return new Engine(); // new is still called — but here, not in business code
}
@Bean
public Car car() {
return new Car(engine()); // manual dependency wiring via method call
}
}What to notice:
newis still used — Spring doesn't eliminate it; it relocates it.- No
@Autowired, no field injection — this is pure constructor wiring. - Dependencies are resolved by calling other
@Beanmethods directly.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Main {
public static void main(String[] args) {
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(AppConfig.class);
Car car = context.getBean(Car.class);
car.drive();
}
}This single line kicks off the entire Spring machinery:
new AnnotationConfigApplicationContext(AppConfig.class);Spring detects @Configuration on AppConfig and treats it as a source of bean definitions. Each @Bean method is registered as a BeanDefinition in the BeanFactory.
Spring does not use your AppConfig class directly. Instead it generates a subclass at runtime:
AppConfig$$EnhancerBySpringCGLIB$$<hash>
This proxy intercepts every call to a @Bean method. This is the foundation of the singleton guarantee.
Consider this code:
@Bean
public Car car() {
return new Car(engine()); // appears to call engine() as a normal method
}You might expect engine() to invoke new Engine() every single time. It does not.
Because AppConfig is a CGLIB proxy, every @Bean method call is intercepted. The intercepted logic is roughly:
// What CGLIB does internally when engine() is called:
if (singletonCache.contains("engine")) {
return singletonCache.get("engine");
} else {
Engine bean = originalAppConfig.engine(); // calls new Engine() once
singletonCache.put("engine", bean);
return bean;
}Result: no matter how many times engine() is called across your @Bean methods, only one Engine instance is ever created.
⚠️ This singleton guarantee only exists because of the CGLIB proxy. Remove@Configurationand you lose it.
// Before
@Configuration
class AppConfig { ... }
// After — now it's a plain component class
class AppConfig { ... }Without @Configuration:
- No CGLIB proxy is generated.
engine()becomes a plain Java method.- Every invocation of
engine()creates a newEngineinstance. - You lose singleton guarantee for beans wired via method calls.
This is why @Configuration is not optional if you care about correctness.
Note:
@Componentclasses with@Beanmethods use lite mode — no proxy, no singleton interception between methods.
Spring never forbids new. It moves the responsibility:
| Without Spring | With Spring |
|---|---|
You call new in business code |
Spring calls new inside @Bean methods |
| You manage object lifecycle | Spring manages lifecycle |
| You wire dependencies manually | Spring wires dependencies |
| No singleton guarantee | Singleton by default |
Internally, Spring still executes:
Object bean = method.invoke(configInstance); // which calls → return new Engine();So new is always used — but only under container control, never scattered across your business logic.
When AnnotationConfigApplicationContext starts, the following sequence runs:
1. Parse @Configuration classes → create BeanDefinition objects
2. Register BeanDefinitions in BeanFactory
3. For each bean:
a. Resolve dependencies (topological ordering)
b. Instantiate object (via constructor or @Bean method)
c. Populate fields / setters
d. Apply BeanPostProcessors (e.g., AOP proxying, @PostConstruct)
e. Store in singleton cache
4. Context is ready
Core class: DefaultListableBeanFactory
Singleton storage: singletonObjects — a Map<String, Object> keyed by bean name
By giving up direct new calls in business code, you gain:
- Singleton management — one instance shared across the app
- AOP proxies — transparent cross-cutting concerns (logging, security)
- Transaction management —
@Transactionalworks via proxies - Automatic dependency injection — no manual wiring in business code
- Lifecycle hooks —
@PostConstruct,@PreDestroy - Circular dependency resolution — handled by the container (see below)
- Testability — easy to swap beans in test configurations
class A {
private B b;
public A(B b) { this.b = b; }
}
class B {
private A a;
public B(A a) { this.a = a; }
}A needs B to be created. B needs A to be created. Neither can go first — a deadlock.
@Bean public A a(B b) { return new A(b); }
@Bean public B b(A a) { return new B(a); }Spring cannot create either bean. It throws:
BeanCurrentlyInCreationException
Why? Constructor injection requires the full dependency before the object exists. There is no window to expose a partial reference.
class A {
private B b;
public void setB(B b) { this.b = b; }
}
class B {
private A a;
public void setA(A a) { this.a = a; }
}Now Spring can:
- Create
A(object exists, no dependencies yet) - Create
B(object exists, no dependencies yet) - Inject
AintoB - Inject
BintoA
The key insight: the object exists before its dependencies are injected.
Spring's circular dependency solution lives in DefaultSingletonBeanRegistry:
Map<String, Object> singletonObjects; // Level 1: Fully initialized beans
Map<String, Object> earlySingletonObjects; // Level 2: Partially initialized beans
Map<String, ObjectFactory> singletonFactories; // Level 3: Factories for early references| Cache Level | Contents | Purpose |
|---|---|---|
singletonObjects |
Fully initialized, ready-to-use beans | Normal bean retrieval |
earlySingletonObjects |
Partially constructed beans | Break circular dependency |
singletonFactories |
ObjectFactory lambdas |
Generate early references (supports AOP) |
Assume A depends on B and B depends on A.
Step 1 — Start creating A:
Spring begins doCreateBean("a")
Before injecting anything, registers early factory:
→ singletonFactories.put("a", () -> getEarlyBeanReference("a", ...))
Step 2 — A needs B, start creating B:
Spring begins doCreateBean("b")
Step 3 — B needs A:
Spring checks for "a":
singletonObjects? ❌ not fully ready
earlySingletonObjects? ❌ not promoted yet
singletonFactories? ✅ found!
Spring calls the factory:
→ Gets early reference to partially constructed A
→ Moves A from singletonFactories → earlySingletonObjects
→ Injects early A reference into B
Step 4 — Finish B:
B is now fully initialized.
→ Move B to singletonObjects
→ Remove B from other caches
Step 5 — Finish A:
Inject fully initialized B into A.
A is now fully initialized.
→ Move A to singletonObjects
→ Remove A from earlySingletonObjects and singletonFactories
Final state:
singletonObjects:
"a" → fully initialized A (holds reference to B)
"b" → fully initialized B (holds reference to A)
The magic line in AbstractAutowireCapableBeanFactory.doCreateBean():
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));Because of AOP proxies.
If a bean has @Transactional or any AOP advice, Spring must return a proxy rather than the raw object as the early reference.
- Level 3 (
singletonFactories) stores anObjectFactorythat can generate the correct proxy when called. - Without level 3, if two beans needed AOP and were circular, Spring would inject the raw object instead of the proxy — breaking AOP.
Level 3 (factory) → called once → produces correct proxy → moved to Level 2
Level 2 (early reference) → used for injection into dependent beans
Level 1 (final) → fully initialized bean
Starting from Spring Boot 2.6, circular dependencies are disabled by default:
# application.properties
spring.main.allow-circular-references=true # must opt-in explicitlyThis is intentional — circular dependencies are a code smell. They suggest your design has tight coupling that should be restructured (e.g., extract a third service, use events, or lazy injection).
| Class | Role |
|---|---|
DefaultListableBeanFactory |
Core bean registry and factory |
DefaultSingletonBeanRegistry |
Manages the three-level singleton cache |
AbstractAutowireCapableBeanFactory |
Handles doCreateBean(), injection, and early references |
AnnotationConfigApplicationContext |
Entry point for annotation-based configuration |
BeanDefinition |
Metadata describing how to create a bean |
BeanPostProcessor |
Hook for post-processing beans (AOP, validation, etc.) |
CGLIB (via Spring) |
Generates subclasses to proxy @Configuration classes |
Q: Does Spring use new internally?
Yes. Spring calls new inside @Bean methods via reflection: method.invoke(configInstance). It relocates new from business code to configuration code.
Q: Does Spring use reflection?
Yes — to invoke constructors, call @Bean methods, inject fields, and trigger lifecycle callbacks.
Q: Why is @Configuration proxied by CGLIB?
To intercept @Bean method calls and enforce singleton scope. Without the proxy, calling engine() twice would create two Engine instances.
Q: How does Spring resolve circular dependencies?
Via a three-level singleton cache. Spring exposes a partially constructed bean through a singletonFactory (level 3) before injection is complete. This early reference lets a circular dependency be injected with an in-progress instance, breaking the cycle.
Q: Why does constructor injection fail with circular dependencies?
Because the object cannot exist until the constructor completes, and the constructor cannot complete without a fully constructed dependency. There is no opportunity to expose an early reference.
Q: What is the singleton cache in Spring?
DefaultSingletonBeanRegistry.singletonObjects — a Map<String, Object> storing fully initialized singleton beans.
What to explore next:
- How
refresh()orchestrates the full context startupBeanFactoryvsApplicationContext— differences and when each matters- How
@Transactionalworks via AOP and CGLIB/JDK dynamic proxies- Lazy beans and prototype scope — when the singleton cache isn't used