IoC and DI Explained: What Exactly Gets Inverted
This article is the conceptual turning point of the whole Spring kernel. The five articles before it taught you how to write code; this one changes who makes the decisions.
In plain words: every class you write needs other classes to work with it (an order service needs a stock repository, a payment client, a mail sender). The question was never "how do they collaborate" but "who supplies those collaborators". The classic answer is to new them yourself inside your code. The Spring answer is that you write only "I need a stock repository", and a dedicated object-butler — the container, i.e. the ApplicationContext object that builds, wires and shelves every bean at startup, waiting for you to pick one up — does the rest.
ordering takeout. You do not need to cook, you do not need to know whose wok is whose or when to light the fire — you tap "one tomato-and-egg dish" in the app and it arrives at your door. News-ing your own dependencies is cooking from buying the groceries every single meal; handing it to the container leaves you with exactly one job: stating what you want. What gets inverted is never the line count but who takes the initiative: previously your code went out to hunt for dependencies; now the container delivers them to you, and your constructor parameters are simply the delivery address.

After this article you should be able to answer three questions:
- Which three concrete duties does IoC invert, and where do DI and DIP each sit?
- At which step of the assembly sequence do constructor, setter and field injection differ, and why is the constructor the official default?
- When A and B need each other, why can field injection survive while constructor injection only produces an error?
Look at this familiar piece of code — it works, until a requirement changes:
package com.example.order;import com.example.pay.AlipayClient;import com.example.stock.MysqlStockRepository;import com.example.mail.SmtpMailSender;public class OrderService { // Dependencies welded in with three "new" calls private final MysqlStockRepository stockRepo = new MysqlStockRepository("jdbc:mysql://..."); private final AlipayClient alipayClient = new AlipayClient("APP_ID", "PRIVATE_KEY"); private final SmtpMailSender mailSender = new SmtpMailSender("smtp.example.com", 465); public void placeOrder(Long userId, Long productId, int count) { stockRepo.deduct(productId, count); alipayClient.pay(userId, productId, count); mailSender.send(userId, "Order paid"); }}It "runs", yet it welds three concrete implementations into the business logic. Change one thing and three break:
- Want to switch to WeChat Pay? You must edit
OrderService's fields and every related call site - Want a fake stock repository in a unit test? Impossible —
new MysqlStockRepository(...)runs the moment the object is constructed, so any test hits a real database - Want the stock service to shard? Back to the business class you go
The root cause is not "ugly code"; it is that the decision of "which implementation to use" has been inverted. OrderService both "processes orders" and "decides which stock, which payment, which mail client" — two responsibilities crammed into one class, so a change on either side ripples into the other.

"Inversion of Control" sounds mystical, but it breaks down into three concrete things:
| What is inverted | Before | After |
|---|---|---|
| Who creates objects | The business class calls new | The container creates them |
| Who wires dependencies | The business class picks impls and assigns them | The container injects by declaration |
| Who owns the lifecycle | Create on demand, let GC collect | Container handles create / init / destroy / singleton cache |
They share one idea: the initiative for creation and wiring moves from the user to an external container. The business class drops from "director + actor" to "actor only" — it no longer cares where its co-star comes from; it just declares "I need a stock repository" when needed.
IoC is not a specific technology but a class of design idea. Spring calls itself a "container" precisely because it took over all three jobs: hand it an interface and the concrete implementation is chosen at runtime.
IoC is the goal; dependency injection (DI) is the primary means. The same dependency can be injected three ways:
@Servicepublic class OrderService { // (1) Constructor injection (recommended) private final StockRepository stockRepo; private final PayClient payClient; public OrderService(StockRepository stockRepo, PayClient payClient) { this.stockRepo = stockRepo; this.payClient = payClient; }}@Servicepublic class OrderService { // (2) Setter injection private StockRepository stockRepo; @Autowired public void setStockRepo(StockRepository stockRepo) { this.stockRepo = stockRepo; }}@Servicepublic class OrderService { // (3) Field injection (least effort, least recommended) @Autowired private StockRepository stockRepo;}| Aspect | Constructor | Setter | Field |
|---|---|---|---|
| Testability | Pass a mock via new, no container | Construct then set | Requires reflection or a container |
| Immutability | final field, fixed after construction | Mutable | Mutable |
| Circular dependency | Exposed immediately, startup fails | Can break the cycle via early exposure | Relies on container leniency |
| Official guidance | Recommended | For optional dependencies | Discouraged |
| Missing dependency | Fails at compile time | Found at runtime | Found at runtime |
Spring's own documentation recommends — constructor injection for required dependencies, setter injection for optional ones, and field injection only for the simplest cases, if at all. The reason is concrete: constructor injection makes it impossible to create an object with incomplete dependencies.
Interviews love to ask: "How do IoC and DIP relate?" The answer is clean: DIP is the design principle; IoC is one means of realizing it.
// X Violates DIP: high-level business depends on a low-level detailpublic class OrderService { private final AlipayClient alipayClient = new AlipayClient(); // depends on a concrete type}// OK Satisfies DIP: both sides depend on an abstractionpublic class OrderService { private final PayClient payClient; // interface only public OrderService(PayClient payClient) { this.payClient = payClient; }}DIP has two inversions:
- High-level modules should not depend on low-level modules; both should depend on abstractions.
OrderService(high level) should not stare atAlipayClient(low level); it should target thePayClientinterface. - Abstractions should not depend on details; details should depend on abstractions. The
PayClientinterface should not know Alipay's signing details — rather,AlipayClientimplements the interface.
So the chain is: DIP says "depend on abstractions" → DI provides the mechanism to "inject a concrete implementation of that abstraction" → the IoC container decides "which implementation to inject at runtime". One line: the principle sets the direction, the container does the dirty work.
new itself is not wrong. The test for violating DIP is not "did you call new" but "does the high-level module depend on a concrete implementation type". Calling new inside a factory method or config class is exactly the right way to flip the dependency direction back.

"Don't call us, we'll call you" is what a Hollywood casting director tells actors: stop phoning to ask if there is a role, show your ability, and we will reach out. The IoC container is that director. The animation above shows the whole sweep; the six cells below can be clicked one by one — cell ③ in particular explains something most people never think about: the creation order is not something you wrote.
Contrast this with the "service locator" pattern, where business code actively calls ctx.getBean(...) — that is "I phone the director", with lookup logic coupled into the class. DI is "the director calls me", and the class is unaware the container exists. Active lookup versus passive reception — that is inversion of control in its most direct form.
Many think DI is just "a few fewer new calls", but the container does far more:
| Capability | Code you would write yourself | What the container provides |
|---|---|---|
| Singleton management | Double-checked locking + static field | scope="singleton" out of the box |
| Dependency resolution | Manual topological sort for creation order | Automatic graph resolution and ordering |
| Lifecycle callbacks | Agree on init/destroy methods and call them on time | @PostConstruct / @PreDestroy |
| Conditional wiring | Hand-written if/else on environment | The @Conditional family |
| AOP integration | Hand-written JDK / CGLIB proxy wrapping | Declare @Aspect and it applies |
| Config injection | Read and parse config files by hand | @Value / @ConfigurationProperties |
the point of this table is not "how great the container is" but to give you evidence when judging "should I adopt a container". When your needs fall in this table, the container saves you hundreds of lines of glue code.
A container is no panacea; all its power rests on the premise that objects need to be managed centrally. For the following, forcing one in is a burden:
// A 30-line data-cleaning script: setting up an ApplicationContext is pure overheadpublic class CsvOneOff { public static void main(String[] args) throws Exception { try (var reader = Files.newBufferedReader(Path.of(args[0]))) { reader.lines() .filter(line -> !line.startsWith("#")) .forEach(System.out::println); } }}- One-off tasks, small CLI tools, a single
mainthat exits — container startup cost and mental overhead are not worth it - Pure algorithms / pure function libraries: nothing to wire, nothing for the container to touch
- Minimal function compute that needs extreme cold-start speed: reflection and scanning slow startup down
In one sentence: a container solves "how objects are assembled". When your code has no collaborating objects at all, there is no IoC to invert.
The demo below shows dependency injection happening for real inside a container. Toggle the conditions and watch the wiring chain react (this one is a kernel lab; the parameter-driven sandbox that shows results instantly lives in Section 11):
Put the four styles on one coordinate system — horizontal: how testable; vertical: whether the dependency can still change after it is set. The choice becomes obvious at a glance. A unit test here means a small test you can run with plain new, without booting the application; a Mock is a stand-in dependency you fabricate to replace the real database or payment gateway.

The chart already shows which quadrant each style sits in. The harder half to remember is the other column: what each style does to you. Tables are easy to skim and hard to recall, so play one round instead — click a style on the left, then click what you think it costs you.
The three styles differ in when the wiring happens, which is the whole answer to "why can a field-injected object be born with nulls". The first experiment shows the cleanest case, constructor injection: the dependency is a parameter, so the object is complete the instant it exists.
The second switches to field injection — same beans, same chain, but assembly moves to after instantiation. Once you see the gap between "instance created" and "dependencies injected", you understand the source of every trap in Section 12.
The third is the most valuable cell: A needs B, B needs A, and both use constructor injection. You will watch the container give up at step four — because at that moment not even a half-built A exists, so there is no reference to lend anybody.
DI is assembling a PC. The motherboard manual only says "a graphics card goes here, PCIe 5.0" — it does not care which brand you bought; any card with the right interface boots the machine. Your class is that motherboard: constructor parameters are the slot specification, and the container is the person who plugs the card in. The tighter you define the slot (final + constructor), the earlier a bad build is caught — nobody trusts a machine whose loose cable only smokes after it has booted.
A circular dependency means two or more objects need each other: A holds B and B holds A. The three-level cache is the trio of Maps the container uses to break such cycles, and the crucial one is level three, singletonFactories — it does not store the object itself but a "pickup slip": a factory able to hand out an early, half-finished reference when someone urgently needs one.

Steps 2 and 3 of the animation are the entire story: instantiation (calling a constructor reflectively to get an empty shell) happens before population (filling the shell with dependencies). Field and setter injection both follow "shell first, contents later", so the shell can be lent out at step 3; constructor injection requires the contents to be arguments, so no shell ever exists and there is nothing to borrow.
Cache on versus off differs in exactly these two log sequences:
| Combination | Outcome | Why |
|---|---|---|
| Singleton + field/setter injection | Starts fine | A half-built object exists right after instantiation, so the slip can be redeemed |
| Singleton + constructor injection | Fails at startup | There is not a single reference to hand over yet |
| Prototype + any injection | Always fails | Prototypes never enter the singleton pool, so there is no warehouse to lend from |
since Spring Boot 2.6, spring.main.allow-circular-references defaults to false, meaning even cycles the three-level cache could have solved are now rejected up front at startup. Do not rush to flip it back to true — that merely permits the bad structure to keep existing; it is not a new solution. The real fixes are extracting the shared part into a third bean, or adding @Lazy at the injection point (see article 10).
Everything so far is about how dependencies arrive. One more distinction matters: does the container hand out one instance of a type or many? That switch is scope — singleton (the default: one instance per container) or prototype (a fresh one per request).
First, see the stations a bean passes from birth to destruction, and where @PostConstruct (initialization callback) and @PreDestroy (destroy callback) land:
Switch the scope to prototype and the last station disappears: once the container hands a prototype over, it lets go completely.
Those two labs only cover singleton and prototype. Real applications add request and session on top, together with two traps of their own: a prototype's destroy callback never runs, and a request-scoped bean injected into a singleton is dead on arrival. The next lab turns "how many live instances exist right now" into a counter you can watch:
Now the question beginners get wrong most often: if I call getBean twice for the same UserService, do I get the same object? Change scope and @Lazy on the left; the right panel immediately shows the hashCode shape and the verdict.
getBean #1 -> UserService@7a3b19getBean #2 -> UserService@7a3b19== comparison: trueSingleton pool singletonObjects: {userService=UserService@7a3b19}# built during startup, so the first lookup costs nothing
UserService@7a3b19 in the sandbox is the hexadecimal hashCode shape printed by toString() — same hash, same object. From now on, the fastest way to settle "did I get the same instance?" is to print one hashCode line in two places instead of guessing.
The sandbox answers "what does each combination produce". The console below answers something different: what this container actually holds right now. Every response is computed by the real Java kernel running in your browser, so type boot first, then beans, and walk the claims of this article one command at a time:
it is worth remembering how beans and di differ — beans reports who is in the container (name, scope, laziness), di reports who survives on whom. Nine out of ten errors in the next section can be pinned down by running one command of each.
Every entry in the first column can be pasted straight into a search box — do not paraphrase or shorten it:
| Error text (excerpt) | Real cause | 30-second fix | Dig deeper in |
|---|---|---|---|
java.lang.NullPointerException: Cannot invoke "com.example.StockRepository.deduct(java.lang.Long, int)" because "this.stockRepo" is null | The object in your hand was new-ed by you, so it never passed through the container and its @Autowired fields were never filled | Take it from the container instead: ctx.getBean(OrderService.class); to prove it, print stockRepo right after construction | Sections 1 & 5 · Article 5 |
org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.OrderService' available | No bean of that type exists: the class lacks @Service/@Component, or its package sits outside the scan path | Check the component annotation, then whether the main class is in a parent package; if both look right, log getBeanNamesForType(OrderService.class) | Articles 5 and 7 |
org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'com.example.PayClient' available: expected single matching bean but found 2: alipayClient,wechatPayClient | Two implementations of one interface were both scanned and the container cannot pick | Name it at the injection point with @Qualifier("alipayClient"), or mark the default implementation with @Primary | The quadrant chart in Section 9 · Article 7 |
org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference? | A and B wait on each other with constructor injection (or prototype scope), so the three-level cache has no half-built reference to lend | Read the ASCII cycle diagram under The dependencies of some of the beans in the application context form a cycle, then switch to field/setter injection or extract the shared part into bean C | Section 10 · Article 10 |
@Autowired on a static field or static method does nothing, and calling it throws NullPointerException | Injection writes values onto an instance member; static belongs to the class, not to any instance, so the container's population step has nowhere to land | Drop static and use an instance member; if you truly need a static utility, pass the dependency in as a parameter | Section 3 |
You added @Qualifier and the container still cannot choose, or two injection points want different implementations | @Primary answers "who is the default" (global fallback); @Qualifier answers "who do I want right here" (local precision) — when both are present, the qualifier wins | Keep exactly one @Primary per interface, write @Qualifier per injection point, or receive everything with Map<String, PayClient> / List<PayClient> | Articles 7 and 8 |
when searching these errors, query only the first English fragment after the colon (for example expected single matching bean but found 2) — hit rates beat the full sentence, since the trailing wording varies across Spring versions.
Row three is the crash this article sees most often. Do not read the answer first — in the real stack below, click the frame you think is guilty; wrong picks come with an explanation too:
You swap the payment implementation from Alipay to WeChat. Your machine runs fine; the colleague who pulls your branch fails half a second after startup.
Compress the whole article into one contrast. You have been walking the left-hand road all along, just without seeing where its bill arrives.

| Situation | new yourself | Hand it to the container |
|---|---|---|
| Swap one implementation | All 20 call sites must change | One config line or one annotation |
| Write a unit test | Drag up a real database and mail server | Pass a mock into the constructor |
| Add a caching layer temporarily | Wrap it by hand at every call site | Mark the caching implementation @Primary |
| Wire the dependency wrong | The compiler will not catch it; humans grep | Startup fails and names the missing party |
IoC is ordering takeout again. You need no cooking skill, no wok, no farm — you place the order and the dish arrives. News-ing dependencies yourself means starting from grocery shopping every single meal. What is inverted is not the amount of code but who acts: your code used to go out hunting for dependencies; now the container brings them to your door while you only keep the delivery address in your constructor signature.
This home-page-sized container demo deserves a few minutes. Flip the condition switches and notice that when a bean's condition fails, the error surfaces at the first downstream bean that needs it, not on the offender itself — which is exactly how the NoSuchBeanDefinitionException row in Section 12 comes to life.
Goal: produce and read the two errors from this article with your own hands. Note that we deliberately keep the container out of it:
package com.example.order;import com.example.stock.StockRepository;import org.springframework.beans.factory.annotation.Autowired;public class OrderService { @Autowired private StockRepository stockRepo; // field injection public void placeOrder(Long productId, int count) { stockRepo.deduct(productId, count); // ← blows up here } public StockRepository getStockRepo() { return stockRepo; // demo only: peek at whether it is null }}package com.example.demo;import com.example.order.OrderService;public class ManualNewDemo { public static void main(String[] args) { OrderService service = new OrderService(); // ← root cause: this object is not in the container System.out.println("stockRepo = " + service.getStockRepo()); // null service.placeOrder(1L, 2); // ← crashes on the next line }}Expected output (on JDK 17+ the NPE message names the missing variable for you):
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "com.example.StockRepository.deduct(java.lang.Long, int)" because "this.stockRepo" is null at com.example.order.OrderService.placeOrder(OrderService.java:12) at com.example.demo.ManualNewDemo.main(ManualNewDemo.java:9)Now let the container instantiate it — add a StockRepository bean and register the service — and the same business line works:
package com.example.demo;import com.example.order.OrderService;import com.example.stock.FakeStockRepository;import com.example.stock.StockRepository;import org.springframework.context.annotation.AnnotationConfigApplicationContext;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configurationclass AppConfig { @Bean StockRepository stockRepository() { return new FakeStockRepository(); // a test double, no database involved } @Bean OrderService orderService() { return new OrderService(); // the container instantiates it, so injection happens }}public class ContainerDemo { public static void main(String[] args) { try (AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class)) { ctx.getBean(OrderService.class).placeOrder(1L, 2); // wiring done by the container } }}Done when: you can say out loud why @Autowired did nothing in the first version — because that object never went through the container's population step.
Three tiny edits, one variable at a time; record what you observe:
- Convert the field injection above to constructor injection and still call
new OrderService(). You will observe the compiler refusing outright withconstructor OrderService in class OrderService cannot be applied to given types— the missing dependency is caught while you type, which is the dividend of the top-left quadrant. - Let
AServiceImplandBServiceImplrequire each other through constructors. You will observe startup failing withThe dependencies of some of the beans in the application context form a cycleplus an ASCII cycle diagram; switch one side to field injection and the three-level cache breaks the cycle immediately. Then replay thecircularlab from Section 10 with caches OFF and watch even field injection collapse. - Annotate
UserServicewith@Scope("prototype")and print the hashCode ofgetBean(UserService.class)from two places. You will observe two different hashes; remove the annotation and they match again — the Section 11 sandbox is the static version of exactly that pair.
Build a small three-injection-styles comparison project that turns this article's claims into runnable evidence.
- One interface
PayClientwith two implementationsAlipayClientandWechatPayClient(each prints a line in its constructor so creation order is visible) - Three business classes obtaining
PayClientvia constructor injection, setter injection and field injection respectively - One
main: boot anAnnotationConfigApplicationContext, print each class's hashCode and whether its dependency is null, then create the field-injected class withnewto reproduce the NPE - Finish by letting both implementations coexist, triggering
NoUniqueBeanDefinitionException, and fixing it once with@Primaryand once with@Qualifierto compare their reach
Acceptance checklist: ① the constructor-injected class is testable with plain JUnit + new, no Spring started; ② the field-injected class fails under the same test and you can name the step responsible; ③ you can explain "who creates the object" in three sentences a colleague understands.
from memory, name the three duties IoC inverts (who creates, who wires, who owns the lifecycle) and give one before/after example for each.
what do IoC, DI and DIP each govern? Chain them into one sentence of cause and effect.
why can constructor injection keep fields final while field injection cannot? Which step ordering explains it?
for an A↔B cycle, field injection survives and constructor injection does not — does the dividing line fall on instantiation or on population? Why do prototypes fail either way?
with two implementations of one type, what problem does @Primary solve and what does @Qualifier solve? When should you collect everything with List<T> instead?
news it yourself means you act; the container supplying you is the inversion. Constructors decide for life; field injection is a restock delivered late.
IoC inverts "who owns creation and wiring"; DI is the means to realize IoC, with constructor injection as the first choice; DIP is the design principle behind it and the container its executor; the Hollywood Principle captures it all in one line — don't call the container, it will call you. To decide whether you need a container, ask just one thing: does your code contain multiple objects that collaborate?