Lesson 1 of 5
Inversion of Control, Built in Miniature
"We are not makers of history. We are made by history." — Martin Luther King Jr., Strength to Love (1963)
Spring is the reason most Java jobs exist, and it is the part of Java that most often gets learned as incantation: annotate the class, inject the field, and something somewhere makes it work. That is a bad position to be in during an interview, and a worse one at three in the morning when the something has decided not to.
So this part does not start with Spring. It starts by building a container — a real one, small enough to read in a sitting, that scans classes, resolves dependencies by type, caches singletons, detects cycles and runs lifecycle callbacks. Roughly sixty lines. Once you have written that, @Autowired stops being magic and becomes an obvious consequence, and the questions interviewers ask about it — why is my bean null, what happens with two implementations, what does a circular dependency actually mean — turn into questions about code you have already written.
A note on this playground. Spring itself needs a servlet container, a classpath scanner and a database that a browser cannot provide, so Spring's own jars are not here. Everything below is plain Java 17 and runs. The reflection the real container uses — runtime annotations, constructor discovery, setAccessible, java.lang.reflect.Proxy — all works here, which is why the miniature is a genuine model rather than a sketch.
If a container just calls constructors for you, why is that worth a framework?
The problem, stated honestly
Before the solution, the thing it replaces. Here is an object graph wired by hand.
Click Run to compile and execute the code.
Nothing in that main is interesting. Every line is forced: SqlRepo needs a Clock, so a Clock must exist first. You are performing a topological sort mentally, in a file edited every time a constructor changes.
The one genuinely interesting decision is hidden in the middle — audit and repo share a Clock. That is a sharing decision, invisible in the types, and getting it wrong with a connection pool rather than a clock is how applications end up with two pools and half the connections they think they have.
Inversion of control is the observation that the mechanical part is derivable. The types already say what each object needs. Something could read them and do the sort for you — and then the sharing decision becomes a declaration rather than an accident of which line you wrote.
Hand-wiring is cooking from a recipe that lists steps but not ingredients: you must work out for yourself that the sauce has to be made before the pasta is drained, and if you get it wrong the dish is cold. A container is a kitchen where you post a note saying this dish needs a sauce and a pasta, and the kitchen works out that the sauce needs a stock, that the stock takes longest, and starts there. The important part is not the labour saved — it is that the kitchen now knows the dependency graph. Because it knows, it can tell you that two dishes are each waiting for the other, that you asked for a sauce and there are three, or that nothing in the kitchen can make a stock at all. Hand-wiring has that knowledge too, but only in your head, where it cannot be checked.
A container, in about sixty lines
Here is the whole idea. Register some classes; ask for one; get a fully-wired object graph.
Click Run to compile and execute the code.
Read instantiate again, because it is the entire mechanism: look at the constructor, ask what types it wants, build each of those the same way, then call the constructor. The recursion performs the topological sort, which is why registering in reverse order produces the identical construction sequence.
And notice what the application classes do not do. None of them mentions MiniContext. None imports it. They declare their dependencies as constructor parameters — which is what they would do anyway — and the container reads that declaration. That is the inversion: the objects no longer control how they are assembled, and they did not have to be changed to give that up.
The real ApplicationContext is this, plus about fifteen years of edge cases. The core loop is the same.
In 2002 Java enterprise development meant EJB 2, where a component needed a home interface, a remote interface, an implementation class and an XML deployment descriptor, and could only be obtained through a JNDI lookup — so every class was coupled to the container that created it. Rod Johnson's Expert One-on-One J2EE Design and Development shipped with thirty thousand lines of code demonstrating a lighter alternative, and that code became Spring. Martin Fowler named the pattern in 2004, in an article that also observed that "inversion of control" was too vague a term, since all frameworks invert control — and coined dependency injection for the specific case. The deep change was not the XML or the annotations; it was that your classes stopped knowing about the framework. An EJB could only run inside an application server. A Spring bean is a plain class with a constructor, which is why it can be unit-tested with new and no container at all — and that testability, more than any feature, is what won.
Singletons, and why identity is the point
The container so far rebuilds everything on every request. Real containers cache, and that cache is not an optimisation — it is the sharing decision from the first block, made explicit.
Click Run to compile and execute the code.
The two pool counts are the chapter's most practical output. Without the cache, a service's repository and its audit log each get their own ConnectionPool — the application works, the tests pass, and in production you have twice as many pools as you budgeted for and half the connections per pool. With the cache, there is one.
That is why singleton scope is the default, and it is also where the most-missed consequence lives: one instance serves every request on every thread. A @Service with a mutable field is shared mutable state between concurrent requests, which is precisely the hazard of Part IV. Dependencies belong in final fields assigned in the constructor; anything about this particular request belongs in a method parameter or a local.
The three failures, and why good messages matter
A container that only works when everything is right is not worth much. Most of a real container's code is about telling you what went wrong.
Click Run to compile and execute the code.
Three failures, three messages, and the wording above is close to what Spring actually prints — NoSuchBeanDefinitionException and NoUniqueBeanDefinitionException are messages you will read many times, and they are now messages you know how to generate.
The cycle is the one worth understanding properly. With constructor injection a cycle is not difficult, it is impossible: OrderService cannot be constructed until BillingService exists, and vice versa. No amount of container cleverness fixes that, because it is arithmetic.
Field injection appears to solve it, and that is the real argument against field injection. Because a field is set after the object exists, a container can construct both halves and patch the references in afterwards — so the cycle survives as a design problem you can no longer see. Spring Boot 2.6 made cycles fail by default for exactly this reason. Constructor injection is preferred not mainly because it allows final fields and easy testing, but because it makes an impossible design impossible to express.
I expected the interesting part of writing the miniature to be the reflection, and it was not — the reflection is about ten lines and worked first time. The interesting part was that almost everything I had filed under "Spring features" turned out to be a consequence of the sixty lines rather than an addition to them. Singleton scope is one Map lookup. The topological ordering is just recursion, which is why registration order genuinely does not matter — I tested that expecting a caveat and found none. @Primary is a filter applied when the candidate list has more than one entry. The circular-dependency error is a Deque you push onto before constructing and pop after. Even the famous distinction between constructor and field injection stopped being a style debate: a constructor argument must exist before the object, a field is set after, and every consequence either way falls out of that one difference. What took the time was the error messages, which is also where most of real Spring's container code goes.
The payoff: substitution
The wiring is the visible benefit. Substitution is the one that actually changed how Java is written, and it costs nothing extra once the inversion is in place.
Click Run to compile and execute the code.
The last section is the whole argument compressed. new UserService(id -> "inline-" + id) constructs the service with a lambda standing in for the repository — no container, no mocking framework, no configuration. That works only because UserService declares its need as an interface parameter and never constructs a SqlRepo itself.
This is the property EJB lacked. A component that can only be obtained from a container can only be tested inside one, which in 2002 meant deploying to an application server to run a unit test. Spring's classes are plain classes with constructors, so the fast test is the default and the container is an optional convenience. The wiring was the feature people noticed; the testability is what changed the industry.
Note also that StubRepo recording its calls is a mock — Mockito generates that for you, but nothing about it requires a library. The seam is the interface, and the interface was already there.
What the real thing looks like
The miniature maps onto Spring almost name for name.
// Real Spring. Same graph, same wiring, same lifecycle.
@Component // a bean definition, found by scanning
class SqlRepo implements Repo {
private final Clock clock;
SqlRepo(Clock clock) { this.clock = clock; } // no @Autowired needed on a single constructor
}
@Service // @Component with a stereotype name
class UserService {
private final Repo repo;
UserService(@Qualifier("sqlRepo") Repo repo) { this.repo = repo; }
}
@Configuration // beans the container cannot construct itself
class AppConfig {
@Bean Clock clock() { return Clock.systemUTC(); }
@Bean @Primary Repo preferredRepo(Clock c) { return new SqlRepo(c); }
}
var ctx = new AnnotationConfigApplicationContext("com.example");
UserController controller = ctx.getBean(UserController.class);The pieces, against what you just built:
@Componentisregister(), except Spring finds the classes itself by scanning a package.@Service,@Repositoryand@Controllerare@Componentwith a name that documents intent — and@Repositoryadditionally translates persistence exceptions.- The constructor is the injection point. Since Spring 4.3, a class with exactly one constructor needs no
@Autowiredat all; the annotation is only required to pick between several. @Beanmethods in a@Configurationclass cover what scanning cannot: types you do not own, like aDataSourceor a third-party client. They are factory methods the container calls, and their parameters are injected the same way.@Primaryand@Qualifierare the ambiguity resolution you implemented — a default, and a selection by name.getBeanisget, andAnnotationConfigApplicationContextisMiniContextwith the scanner attached.
One thing the miniature does not model: Spring instantiates all singletons eagerly at startup, not lazily on first getBean. That is a deliberate choice — it means a missing bean, an ambiguous type or a cycle fails at startup rather than on the first request.
Nearly every "why is my bean null?" has one of three causes, and the miniature explains all of them. The class is not a bean — no stereotype annotation, so it was never registered. The class is outside the scanned packages — component scanning starts at the @SpringBootApplication class's package and goes down, so a bean in a sibling package is invisible. Or the object was created with new, which is the subtle one: an object you construct yourself is not managed, so its @Autowired fields are never populated and its @Transactional annotations do nothing, because the container never got the chance to wrap it. That last case is worth internalising — the annotations only mean something on an object the container made. It explains why calling an annotated method from inside the same class also does nothing, which is the self-invocation problem we will take apart when we build the proxy.
Common Mistakes
Putting request state in a
@Servicefield. One singleton serves every request on every thread; a mutable field is shared mutable state.Creating a managed class with
new. The container never saw it, so nothing is injected and no annotation applies.Expecting a bean outside the scanned packages to be found. Scanning starts at the application class's package and descends.
Using field injection to break a cycle. It hides an impossible design rather than fixing it, which is why Boot 2.6 disallows cycles by default.
Adding
@Autowiredto a single constructor. Unnecessary since Spring 4.3 — it is only needed to choose between several constructors.Injecting a concrete class where an interface is meant. It works, but removes the seam for a test double or a proxy.
Reaching for
@Qualifierbefore@Primary. A sensible default serves most call sites; qualify only the exception.Assuming
@Componentworks on a type you do not own. You cannot annotate a library class — that is what@Beanmethods in a@Configurationclass are for.Treating the container as a service locator. Injecting
ApplicationContextand callinggetBeanre-couples the class to the framework and discards the inversion.Thinking singleton scope means thread-safe. It means precisely one shared instance, which is the opposite guarantee.
Interview Corner
@Component and its stereotypes, and when you ask for a type it finds the definition that satisfies it, recursively builds that definition's constructor arguments, calls the constructor, and caches the result as a singleton. The inversion is that application classes never mention the container — they take constructor parameters, which is what they would do anyway — so they can also be unit-tested with plain new and no framework at all. That testability is the main practical win, more than the wiring.final fields, makes dependencies visible in the signature, and lets you construct the object in a test without a container or reflection. But the deepest reason is about circular dependencies. A constructor argument must exist before the object does, so a cycle is not merely awkward, it is unsatisfiable — neither object can be built first, and the container fails at startup. A field is set after construction, so a container can create both objects half-built and patch the references in afterwards, which means the cycle survives as a design problem nobody can see any more. Spring Boot 2.6 disallowed cycles by default for that reason. The other practical point is that a class with eight constructor parameters looks wrong, and should — field injection lets that class hide, so you lose the signal that it is doing too much.@SpringBootApplication package downwards and registers a bean definition for every stereotype-annotated class, plus every @Bean method on @Configuration classes — definitions first, instances later, which is what allows ordering and post-processing. Then bean-factory post-processors run and can modify those definitions. Then the container instantiates the singletons eagerly, resolving each constructor's parameter types recursively: for each parameter it finds candidate definitions by assignability, and if there is more than one it applies @Primary or @Qualifier, failing with NoUniqueBeanDefinitionException if neither disambiguates, or NoSuchBeanDefinitionException if there are none. Cycles are detected because a bean currently in creation is tracked, and by default they are now an error. After construction, bean post-processors run — which is where @Transactional and the other AOP annotations replace the bean with a proxy — then @PostConstruct callbacks fire. Eager instantiation is the deliberate part: a missing or ambiguous bean fails at startup rather than on the first request. By the time a request arrives, everything is built, and the dispatcher is routing into a singleton controller — which is why that controller must not hold request state in a field.@Autowired does, constructor versus field.Hands-on
Encode the container's rules. Implement resolve, picking a definition by assignability and @Primary; constructionOrder, the topological rule; cycleIsFatal, which injection style makes a cycle impossible; sharedInstances, what singleton scope means for counts; and failure, naming the exception. A stub scores zero, so a passing run means you encoded the rules rather than guessed.
Click Run to compile and execute the code.
Summary
- A container reads constructor parameter types and builds the graph depth-first — the recursion is the topological sort, so registration order is irrelevant.
- The inversion is that your classes never mention the container. They declare dependencies as constructor parameters, which is what they would do regardless.
- Singleton scope is one
Maplookup, and it exists to make the sharing decision explicit rather than accidental. - One instance serves every request on every thread. Dependencies in
finalfields; request state never in a field. - A constructor cycle is unsatisfiable, not merely awkward — neither object can exist first, which is arithmetic rather than a container limitation.
- Field injection hides cycles by patching references after construction, which is the strongest argument against it; Boot 2.6 disallows cycles by default.
@Primaryis a default and@Qualifieris a selection — both are filters applied when more than one candidate matches.@Beanmethods cover what scanning cannot: types you do not own, constructed by a factory method the container calls.- Spring instantiates singletons eagerly at startup, so missing, ambiguous and circular beans fail before the first request.
- Annotations only act on objects the container made. Anything built with
newis unmanaged, which explains most "why is my bean null".
You now have a container that constructs beans. The next chapter is about everything that happens to a bean between construction and use — the scopes beyond singleton, the lifecycle callbacks, and the post-processors that are the hook every other Spring feature is built on.
What strikes me, having built one, is how small the idea is relative to its reputation. Sixty lines of reflection, a Map for the cache and a Deque for cycle detection account for nearly everything people describe as Spring's magic. The remaining bulk of the real container is not cleverness — it is error messages, configuration formats, and twenty years of accommodating what people actually did. I think there is a general lesson about frameworks in that. What makes them feel impenetrable is rarely conceptual depth; it is that the core idea is buried under the accumulated weight of edge cases, so you cannot see the shape. The fastest way through is almost always to build the smallest thing that does the central job, because afterwards the real one reads as your program plus a list of concerns you had not yet met — and a list of concerns is something you can work through, which an impression of magic is not.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.