What Spring Really Is: From the Pain of EJB to the Birth of IoC
In one line: Spring is a container that builds objects for you, hands them to you, and manages them. Before it, whenever a Java class needed another, you wrote new. After it, you write only "I need something that can query orders", and everything else — where it comes from, how it is assembled, when it is destroyed — is arranged by the container. This article explains why it grew this shape: in 2003 the Java world was suffocating under EJB ceremony, Rod Johnson proved in a book that "plain Java classes plus dependency injection" could do the same job, Spring was born from that code, and later grew Boot (which removes configuration) and Cloud (which coordinates many services).
**ordering takeout is inversion of control. You want a plate of tomato-and-egg stir-fry. The manual way is to shop, wash, chop and cook — you are both the diner and the kitchen. Ordering takeout only requires placing an order (declaring "I want this dish"); the wok, the ingredients and the packaging are the restaurant's (the container's) decision. The day they change chef, pan or box, your order does not change by a single word. That is exactly what "inversion" inverts: initiative over cooking moves from your hands to theirs.**

After this article you should be able to answer three questions:
- What made EJB so painful, and why is "non-invasive" Spring's founding creed?
- "Inversion of control" sounds mystical — which four things actually get inverted, and how does that differ from "dependency injection"?
- If I already use Spring Boot, why go back and learn the Spring container?
To understand why Spring was great, you have to know what it rescued us from. Twenty years ago the standard answer for Java enterprise development was EJB (Enterprise JavaBeans). It looked respectable and was painful to write:
// Typical EJB 2.x style: implement container interfaces, write a pile of callbackspublic class OrderServiceBean implements SessionBean { private SessionContext context; // Only the last line is business logic; the rest is container ceremony public void ejbCreate() {} public void ejbActivate() {} public void ejbPassivate() {} public void ejbRemove() {} public void setSessionContext(SessionContext ctx) { this.context = ctx; } public BigDecimal total(Order order) { return order.getItems().stream() .map(Item::getPrice) .reduce(BigDecimal.ZERO, BigDecimal::add); }}And to use this bean, you cannot call new — you must look it up via JNDI:
// To reuse it elsewhere, look it up in the directory service and handle checked exceptionsContext ctx = new InitialContext();OrderServiceHome home = (OrderServiceHome) ctx.lookup("java:comp/env/ejb/OrderService");OrderService service = home.create();Three fatal problems stand out today:
- Heavy intrusion: business classes had to implement container interfaces, welding code to the runtime environment
- Deploy to test: without a heavyweight EJB container nothing ran — unit testing was nearly impossible
- Boilerplate drowned the business: five lines of logic needed fifty lines of ceremony
EJB's greatest sin was not "being complex" but complicating the simple. A method that only "sums a total" got wrapped in container interfaces, lifecycle callbacks and remote lookups — exactly the target Rod Johnson later attacked.

Cut the same timeline by "who does what for you" and you get the zoomed-in version of the map at the top of this article: Spring owns wiring, Boot owns configuration, Cloud owns service-to-service concerns:

In 2002 the Australian developer Rod Johnson wrote Expert One-on-One J2EE Design and Development. The book did not teach "how to use EJB better"; it did something bolder: argue that many situations do not need EJB at all.
It shipped roughly 30,000 lines of example code that delivered EJB's core capabilities using plain Java objects (POJOs) plus dependency injection. Readers kept asking where that code was and how to reuse it — and it evolved into the Spring Framework, open-sourced in 2003.
The most memorable point is this: Spring did not start by "inventing new technology" but by "solving enterprise problems with ordinary Java classes". Its creed is non-invasiveness: your business class is a plain class, extends nothing, runs inside Spring, still runs without Spring, and can be tested with plain JUnit any time.
the name Spring comes from the idea of "bringing spring to J2EE". Its tagline back then was "J2EE without EJB" — getting EJB's power the lightweight way.
Spring's core is called IoC (Inversion of Control), a term that scares off countless beginners. What it describes is very plain.
A metaphor: you are making tomato-and-egg stir-fry. The traditional way is to shop, wash, chop, and only then cook — you worry about getting the ingredients. The IoC way: you write only the recipe (declare "I need eggs and tomatoes"), and the kitchen (the container) delivers prepared ingredients to your side, so you focus on cooking.
In code, look first at the "new it yourself" world:
// Bad: the dependency is hard-coded inside the classpublic class OrderController { // This class decides which implementation to use; business code is hostage to the detail private final OrderService service = new OrderServiceImpl(new MysqlOrderRepository()); public BigDecimal checkout(Long orderId) { return service.total(orderId); }}The problem is obvious: to switch to RedisOrderRepository you edit OrderController itself. To inject a fake service in a test? Impossible — it hard-codes new.
Now the IoC world:
// Good: declare only "I need an OrderService"; who provides it is not my concern@Servicepublic class OrderController { private final OrderService service; // The constructor is a contract: the container sees it and injects an OrderService public OrderController(OrderService service) { this.service = service; } public BigDecimal checkout(Long orderId) { return service.total(orderId); }}- There is no
newin the class, hence no decision about which implementation - That decision moves to the container, which reads your constructor and injects the right implementation
- Result: to swap the implementation, just have another
OrderServicebean; to test, simplynew OrderController(mockService)
That is what "inversion" means: once you stop calling new and instead declare a dependency for someone else to supply, control over object creation has left your hands.
The wall between the jargon and its plain meaning is real, because nobody explains it. Play a round: the left column is what documentation says, the right column is what you will actually see at an incident scene — a wrong pick explains the gap immediately:

"Inversion of control" sounds abstract until you split it into four observable dimensions:
| Dimension | Before (self new) | After (container injection) |
|---|---|---|
| Creation | The consumer calls new | The container instantiates |
| Wiring | The concrete implementation is hard-coded | The container wires per declaration |
| Lifecycle | The consumer owns it | The container manages it (singleton, destroy callbacks) |
| Swapping implementations | Requires editing source | Change an annotation / config |
| Testability | Poor, dependencies welded in | Good, mocks can be injected |
In one line: what gets inverted is not "who calls whom" but "who creates, wires and manages the object end to end". Business code keeps only "what I need"; everything else is outsourced to the container.
interviews love to ask "are IoC and DI the same thing?" Precisely: IoC is the idea (hand over control), DI (dependency injection) is the mechanism (deliver dependencies via constructor, setter or field). Spring implements IoC using DI.
hiring a renovation company is the same transaction. Before inversion you are both the owner and the foreman: you buy the cement, find the tiler, chase the schedule and fix the leaks yourself. After inversion you only sign one contract with the firm (write your constructor parameters); the firm sources materials, sequences the trades, inspects and honours the warranty (the container creates, wires, manages the lifecycle and destroys). What you hand over is the right to decide who does the work and how; what you get back is the freedom to only state requirements. That is precisely what the four rows of the table above say.
Unpack that contract and the firm takes over six jobs. This animation plays them in order — note frame ③: a missing dependency explodes before the object is built, not when you finally call it:

The sixth job (destroy callbacks) is the one beginners file under "the JVM handles that". Switch to "watch destroy" and see whether those callbacks really fire, and why prototype beans are not on the list:
Spring is not a single framework but a whole family. Ordered by how deeply you will touch it:
| Module | What it solves | Where you meet it |
|---|---|---|
| spring-core / beans | The IoC container and dependency injection core | The foundation of everything |
| spring-context | ApplicationContext, events, i18n | Reading config, publishing events |
| spring-aop | Aspect-oriented programming | Logging, security, the base of declarative features |
| spring-tx | Declarative transactions | @Transactional |
| spring-jdbc / orm | Data access and ORM integration | JdbcTemplate, JPA |
| spring-web / webmvc | Web and the MVC layers | @RestController, @GetMapping |
| spring-security | Authentication and authorization | Login, permissions |
| spring-test | Testing support | @SpringBootTest |
| spring-boot | Auto-configuration and starters | The default entry for modern projects |
Keep one thread in mind: the higher you go, the closer to business; the lower you go, the closer to the core. Questions like "why does this annotation work" or "why didn't the transaction roll back" are usually answered in the lower layers (aop / tx / context).
don't start by chewing through the whole family. Master core + context (that is IoC) first; every later module just adds capability on top of the same container — the essence is unchanged, only the capabilities grow.
This confuses beginners and interviewers alike. Think of Spring Boot as "a scaffold for the scaffold": it is not a replacement framework but a launcher built on top of Spring that removes configuration for you.
| Aspect | Spring Framework | Spring Boot |
|---|---|---|
| Role | Provides IoC, AOP, MVC and other core capabilities | Auto-configuration and packaging on top of Spring |
| Configuration | Write XML / Java config by hand | Convention over configuration, works out of the box |
| Dependencies | You pick compatible versions yourself | Starters bring a compatible set at once |
| Startup | Needs an external container or manual wiring | Embedded Tomcat, run main directly |
| Relationship | The foundation | The show home built on it |
Three sentences on what Boot actually does for you:
- Auto-configuration: sees
spring-webmvcon the classpath and configures a DispatcherServlet for you - Starters: one
spring-boot-starter-webpulls a whole compatible set of web dependencies - Embedded container: Tomcat ships inside the jar, so
java -jar app.jarstarts the service — no external deployment
"with Spring Boot you don't need to understand Spring" is the biggest misconception. Auto-configuration decides for you, but when something breaks you must be able to read what it did. To know why a bean was created, you return to spring-context knowledge and debug — you don't expect Boot to explain itself.
That timeline appears in Section 2 as five names, but what each step is really worth remembering is "what it saved you and what it left you owing". Click through it — the last two boxes are where you are standing today:
"But I can use the annotations — isn't that enough?" Here are three real reasons it is not:
- Debugging: errors about circular dependencies, broken AOP proxies or a transaction that did not roll back — without the container's mechanics you are stuck with search-and-guess; with them you locate the cause directly
- Reading source and docs: Spring's official docs and community articles assume you know IoC and the Bean lifecycle; without the core, much of it cannot be applied
- Interview essentials:
Bean lifecycle,circular dependency three-level cache,AOP proxying,transaction propagationare guaranteed Java backend interview topics, almost always asked as principles
Try the container yourself and watch how beans get wired — flip the switch in the lower right and see how the assembly changes:
learning Spring goes "use it, then understand it, then debug it". Skip the middle step and you stay stuck at "copy-paste works, errors don't" — exactly why this course insists on explaining why first.
If you picked B, the next question is "which dependencies does this project need". Do not copy somebody's pom — tick it once instead: for every box the generator explains why that line exists and what disappears without it.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>"EJB is heavy, Spring is light" is a historical claim. What actually sticks is counting the steps yourself. The lab below has four presets — press them in order. First 「12 manual steps」 to see how much you must write without a framework; then 「Let the container」 to see which few steps remain; then 「One step more: Boot」 to see what happens when even configuration is removed; finally 「Cost and payoff」 to see what the framework charges you. Do not read on until you have pressed all four.
After the lab, lock the conclusion with this side-by-side diagram: every line in the left column serves the environment; every line in the right column expresses business.

the hand-written version is not something to avoid. Quite the opposite — writing one full JDBC round trip by hand is how you learn precisely what Spring deleted. Article 27 of this course goes back and writes it.
Section 4 says inversion moves the right to create. But once that right is handed over, how is the object physically assembled? This lab puts the three styles side by side: constructor injection (the engine must be present when you collect the car), setter injection (collect the chassis, fit the tyres later), field injection (someone slips a part into your car while you are away). Finally switch to 「With a cycle」 and see why the same deadlock kills constructor injection on the spot while field and setter survive.
the three styles are three ways of receiving a delivery — constructor injection is "sign only when the parcel is complete" (fail fast); setter injection is "drop the box at the door, install it later" (re-fittable, swappable); field injection is "something appeared in your home and you never saw who put it there" (least code, zero visibility).
How does that "sign on delivery" step actually happen? Spread it into a single-step run: five lines of main on the left, the current variables and the container's stack depth on the right. Step through and watch beats 3 and 4 — the object is already built by beat 3; beat 4 only reads it out of the pool:
ApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class);// ① read config: every @Service becomes a BeanDefinition// ② refresh(): pre-instantiate all non-lazy singletons// ③ build orderController: first ask what its constructor wants// ④ find candidates by type → orderService → build it recursivelyHelloController h = ctx.getBean(HelloController.class); // just a pool read| ctx | AnnotationConfigApplicationContext |
| registered blueprints | 0, about to be read |
new AnnotationConfigApplicationContextNow run the most common real change through the animation: product asks the order repository to move from MySQL to Redis. The manual road means hunting every new; miss one and you get a runtime NullPointerException. The container road changes only the declaration, and the container validates the rest at startup.

Many people believe "write a <bean> tag and the object appears". There are two clearly separated legs: **first the XML is read into blueprints (BeanDefinition — metadata describing how to build the object; not the object itself)**, then objects are built from those blueprints. The lab has four presets: Read & parse, Register definitions, getBean in action, A typo in class. Press the last one especially — it explains why a wrong class name in XML produces no editor warning yet explodes at startup.
these legs become your own runnable project in article 5. For now keep one impression: annotations and XML are two spellings of the same thing, and both end up as the same blueprint.
The three labs above show process; this one shows decisions. It runs a small Java container for real inside your browser: toggle a condition switch and the container re-wires beans live, writing "why this bean was skipped" into the log. Try turning the data-source switch off and see what happens to everything downstream that depends on it.
After four labs, switch to typing. This console is wired to the same container running in your browser and every answer is computed there — start with beans to see how many objects the container really holds, then di, then work through the lab lines:
run di straight after beans — the same objects appear in two views: "what exists" and "who depends on whom". The second one is what the wiring-rights row of Section 4's table is talking about.
"Should we use Spring" is not a question of faith; it is arithmetic. The sandbox gives you three variables: how many objects collaborate, whether implementations must be swappable, whether unit tests are required. Move them and the verdict plus its cost estimate appear immediately.
# Only 3 objects, no swapping, no testsManual assembly cost: ~10 minutes (three new lines)Container adoption cost: ~2 days (dependency + config + team ramp-up)Verdict: not worth it -- just call new
the only question that matters is whether your code contains multiple objects that must collaborate. If yes, the container saves you glue code; if not, forcing one in only slows the cold start and adds a black box.
The first question tests intuition — get it right and the takeout metaphor has landed:
The second is broader, tying sections 6 and 7 together:
This section is your self-rescue entry point. Copy the verbatim fragments into a search engine; do not paraphrase them — search matches strings, not meanings.
| Verbatim fragment | Real cause | 30-second fix | Where it goes deep |
|---|---|---|---|
Exception in thread "main" java.lang.NullPointerException at com.example.OrderController.checkout(OrderController.java:14) | You never constructed the collaborator, or you expected the container to fill the field while the class itself never entered the container | Follow the top stack frame to the field that is null. Either construct it honestly with new, or let the container build this class (@Service plus fetch from the container) | Section 4 here; article 6 |
An @Autowired field is null at runtime and startup printed no error | The object came from new XxxService(), so it never passed through the container and nobody injected it | Grep for new in front of that class name. Any class needing injection, transactions or aspects must be taken from the container, never hand-built | Article 5 section 8; article 6 |
org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.OrderService' available | No bean of that type exists in the container: the class lacks @Service/@Component, or its package is outside the scan path | Check the component annotation first, then whether the main class sits in a parent package. If both look right, log getBeanNamesForType to confirm | Articles 5 and 7 |
org.springframework.beans.factory.NoUniqueBeanDefinitionException: ... expected single matching bean but found 2: mysqlOrderRepository,redisOrderRepository | Two implementations of one interface were both scanned and the container cannot pick | Add @Qualifier("mysqlOrderRepository") at the injection point to name it, or mark the default with @Primary | Article 6 section 3; article 7 |
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'orderService': Requested bean is currently in creation: Is there an unresolvable circular reference? | A's constructor needs B while B's constructor needs A, so each waits for the other to be born | Do not switch on allow-circular-references yet. Extract the shared logic into a third bean, or put @Lazy on one injection point | Article 10 |
java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver | Lesson one of the hand-written era: the driver jar is not on the classpath, or driver registration was never triggered | Verify the coordinates (mvn dependency:tree) and confirm the class exists in the packaged jar; with JdbcTemplate the template owns this step | Articles 2 and 27 |
"Should I use BeanFactory or ApplicationContext?" (a choice, not an exception) | They differ in layer, not in age: the former is the lowest-level interface (create on demand, lazy), the latter adds events, i18n, resources and auto-proxying and pre-instantiates every non-lazy singleton at startup | Real projects always use ApplicationContext. Consider a bare BeanFactory only for embedded, cold-start-critical cases; remember that failing-at-boot errors are the benefit of pre-instantiation | Article 5 section 6; article 8 |
when searching an error, query only the first English fragment after the colon (for example expected single matching bean but found 2). Hit rates beat full sentences, since the tail wording varies across versions.
The NoSuchBeanDefinitionException in the third row above is almost everybody's first Spring error. Do not memorise it — click the guilty frame instead, and note that the top line is not the answer:
You added a constructor parameter exactly as Section 4 prescribes, and on startup the console fills with red — before a single line of your code ran.
Goal: run the minimal "ordering takeout" version in five minutes — one interface, two implementations, and you personally play the container deciding which one is handed over. No Maven needed; a JDK is enough.
package com.example.warmup;// (1) The abstraction: this is the only menu the customer knowsinterface CoffeeMaker { String make();}// (2) Implementation one: the in-shop machineclass ShopCoffeeMaker implements CoffeeMaker { @Override public String make() { return "freshly ground in shop"; }}// (3) Implementation two: delivered coffeeclass DeliveryCoffeeMaker implements CoffeeMaker { @Override public String make() { return "delivered to my door"; }}// (4) Business class: declares what it needs, chooses nothingclass Customer { private final CoffeeMaker maker; // constructor injection lets the field be final Customer(CoffeeMaker maker) { this.maker = maker; } String drink() { return "I am having: " + maker.make(); }}public class Main { public static void main(String[] args) { // (5) Right now *this* block is the container: the choice lives outside the user CoffeeMaker chosen = new DeliveryCoffeeMaker(); Customer customer = new Customer(chosen); System.out.println(customer.drink()); // (6) Swap the implementation: the business class is untouched Customer another = new Customer(new ShopCoffeeMaker()); System.out.println(another.drink()); }}Compile and run:
javac -d out src/com/example/warmup/Main.javajava -cp out com.example.warmup.MainExpected output:
I am having: delivered to my doorI am having: freshly ground in shopCheck three things: ① Customer contains no new CoffeeMaker anywhere — it only names the interface; ② swapping the implementation touched only Main; ③ maker is final, immutable after construction — the two reasons article 6 recommends constructor injection.
Goal: watch "forgot to inject" happen with your own eyes.
Do it: delete that hand-written-container block from Main and instead declare private CoffeeMaker maker = null; inside Customer, changing nothing else, then call customer.drink().
You will observe: it compiles, it starts, and only the moment you call it does NullPointerException explode. Copy that stack trace into your notes and label it — it is the prototype of row one in the error table above. Extend the variant: add setMaker(...) to Customer, call drink() right after construction (boom), then add one setMaker(...) (alive). That is exactly the price of setter injection: an object can exist in a half-built state.
Goal: build a tiny "three restaurants + one customer" project and feel what a container actually does for you.
Requirements: define a Restaurant interface (with a dish() method), write HotpotRestaurant, SushiRestaurant and CanteenRestaurant; then write a minimal MyContainer holding a Map<String, Restaurant> of "name → instance" and exposing only pick(String name). Customer obtains restaurants through MyContainer and never news an implementation.
Acceptance checklist:
- [ ] Grepping
Customerfinds no concrete implementation class name (interface only) - [ ] Adding a fourth restaurant requires registering it in
MyContainerand changing no business class - [ ]
MyContainer.pick("not-exist")is handled deliberately, and you can name the Spring exception it corresponds to - [ ] You can point at the four rows and say who now owns creation, wiring, lifecycle and implementation swapping
- [ ] Write down which three capabilities your
MyContainerlacks compared with Spring (hints: scope, init callbacks, lookup by type)
without notes, explain inversion of control to someone who has never coded using the takeout metaphor, and be clear about what you hand over and what you keep.
section 4 lists four dimensions of inversion. Can you recall three from memory? Miss one and go back.
which of IoC and DI is the means? Answer in one sentence that must contain the words "idea" and "mechanism".
what is the practical difference between BeanFactory and ApplicationContext? The keyword should be instantiation timing, not old versus new.
for NoUniqueBeanDefinitionException in the error table, can you say when to use each of the two fixes? (Hint: one says "this particular call site is special", the other says "normally, this one wins".)
Order, do not cook — inversion hands over creation; blueprints (BeanDefinition) land first, the container ships objects after.
Spring has proven its values over twenty years — non-invasive, testable, interface-oriented. Its core, IoC, does one thing: it frees you from "newing everything yourself", letting you declare dependencies and handing creation and wiring to the container. Understand that layer and every Spring module (AOP, transactions, MVC, Boot, Cloud) is just capability stacked on the same container — no longer a pile of annotations to memorize.