DEV Community

Ankit Verma
Ankit Verma

Posted on

Why Spring exists; the IoC container in one analogy

Spring is a framework that builds and connects the objects your application is made of. You write the classes. Spring creates them, wires them together, and hands them to each other at startup. That sounds like a small favor. It quietly reshapes how the whole codebase fits together, and almost every other Spring feature is built on top of it.

You meet this on day one, usually before anyone names it for you. You open a Spring project, find a class with no new anywhere in it, and wonder where its dependencies actually come from. The answer is the mechanism this article is about.

So this first piece is about why that job needs to exist at all, and the single mechanism Spring uses to do it. Once that mechanism is clear, most of Spring stops looking like magic.

Let's start with no Spring at all, and watch the problem show up on its own.

A class that builds its own tools

Here is a class that needs another class to do its job. The natural instinct is to build what it needs, right where it needs it, with new:

class OrderService {
    private final PaymentGateway gateway = new PaymentGateway(new HttpClient());

    void placeOrder(Order order) {
        gateway.charge(order.total());
    }
}
Enter fullscreen mode Exit fullscreen mode

Look at what OrderService now owns. It places orders — that is its real job. But it also knows how to build a PaymentGateway. And because a gateway needs an HttpClient, it builds that too. One class doing two jobs: its actual work, and the plumbing underneath it.

That second job is where the trouble starts. It costs you three concrete things, and each one points to what comes next.

You can't test it in isolation. The real PaymentGateway calls a real bank. It is baked in with new, so there is no way to slip a fake one in for a test. To test the order logic, you'd have to charge actual money.

The plumbing gets copied everywhere. Every class that needs a gateway writes the same new PaymentGateway(new HttpClient()). Change how a gateway is built, and you have to hunt down every copy.

There is nowhere to add shared behavior. Suppose you want every payment wrapped in a database transaction. With the gateway hard-wired by new, there is no single seam to hook that in.

Handing over control

Now the same class, written the way Spring wants it. It stops building anything. It just states what it needs:

@Service
class OrderService {
    private final PaymentGateway gateway;

    OrderService(PaymentGateway gateway) {   // "I need a PaymentGateway"
        this.gateway = gateway;              // someone hands one in
    }
}
Enter fullscreen mode Exit fullscreen mode

OrderService no longer knows or cares how a PaymentGateway is built. It declares the need in its constructor and trusts that a finished one will arrive from outside.

That flip has a name. Normally your code is in control of creating its own collaborators. Here, your code gives that control away — something else creates the objects and supplies them. Handing that control to the framework is called Inversion of Control, or IoC. The "control" is specifically control over creating and wiring objects. The "inversion" is that it now runs the other way around: the framework builds your objects and calls into them, instead of your objects building everything themselves.

The way the gateway actually arrives — through the constructor above — has its own name: Dependency Injection, or DI. The dependency, the gateway, is injected into OrderService from outside. It can arrive through a constructor, through a setter, or straight into a field. Prefer the constructor: the dependency is visible right there in the signature, and the field can be final, so the object is complete the moment it exists.

So IoC is the idea — your code no longer creates its own collaborators. DI is the delivery — how each collaborator gets in. Which raises the obvious question: who actually does the creating and the handing-over?

The container: a factory that runs at startup

The thing that creates and wires your objects is the container. In Spring it is an object called the ApplicationContext.

Here is the one analogy worth holding onto: the container is a factory. Not "factory" in the design-pattern sense — picture a literal assembly line that runs once, when your application starts. You hand it a list of what to build and how the pieces fit together. It produces every finished, connected object your app is made of, sets them on a shelf, and hands them out on request for the rest of the program's life. Everything below is just detail about how that factory runs.

Two small words have been quietly working and now deserve a definition. To wire two objects is just to give one a reference to the other — to set OrderService's gateway field to a real PaymentGateway instance, so the call inside placeOrder has something to land on. And a bean is simply an object that the factory creates and manages for you. Your @Service, your @Component, the things Spring builds — those are beans. An object you build yourself with new is not a bean, because Spring never touched it. That distinction looks small now; it matters in a minute.

Here is what the factory does, in order:

  1. It scans your code and writes down recipes, not objects. When Spring sees @Component, @Service, or a method marked @Bean, it does not build anything yet. It records a bean definition: which class, how many copies to make, what that bean depends on, and any startup hooks. Think of it as a recipe card, not the finished dish.
  2. It works out the dependency graph. From those recipes it figures out who needs whom. OrderService needs PaymentGateway, which needs HttpClient. A short chain here; in a real app, a wide web.
  3. It builds the objects bottom-up. You can't inject a PaymentGateway before it exists, so Spring builds the leaves of the graph first and works upward, passing each finished bean into the next one's constructor.
  4. It caches each finished bean and reuses it. Once a bean is built, the container keeps it and hands out that same instance to everyone who needs it. It does not build a fresh one each time.

That third step is worth making concrete, because the order is the whole point. Take the chain from earlier: OrderService needs a PaymentGateway, which needs an HttpClient. The factory cannot build OrderService first — it has nothing to put in the constructor yet. So it walks the chain from the bottom:

  1. Build HttpClient. It depends on nothing, so it is a leaf and goes first.
  2. Build PaymentGateway, passing in the HttpClient from step 1.
  3. Build OrderService, passing in the PaymentGateway from step 2.

Three objects, built in the one order that lets each constructor receive a finished dependency. You never wrote a line of that sequencing. The factory read it off the graph and did it for you — and the same logic scales from this three-link chain to a graph with thousands of beans.

By the time startup finishes, every bean exists and every dependency is in place. Asking for one is now instant, because the work already happened:

ApplicationContext ctx = SpringApplication.run(App.class, args);
OrderService svc = ctx.getBean(OrderService.class); // already built and wired
Enter fullscreen mode Exit fullscreen mode

The extra move: wrapping a bean in a proxy

There is one more thing the factory can do while it builds a bean, and it explains a surprising amount of Spring. While assembling a bean, the container can wrap it in a proxy before handing it over.

A proxy is a stand-in object. It looks exactly like your bean — same type, same methods — but it runs a little extra code before and after each call, then forwards to your real object. Callers cannot tell the difference. They think they are holding your bean directly.

That one trick is the real answer to a whole family of "how does Spring even do that?" questions:

  • @Transactional works because the proxy opens a database transaction before your method runs, and commits it after the method returns.
  • @Async works because the proxy hands your call off to run on another thread.
  • Security and caching annotations work the same way — extra code wrapped neatly around your untouched method.

This is also the seam the third cost was missing earlier. Back when OrderService built its own gateway with new, there was nowhere to slip in "wrap every payment in a transaction." Now there is: the gateway arrives from the factory, so the factory gets one chance, at build time, to hand back a wrapped version instead of the bare one. You wrote a plain method; the container wrapped it at startup. Hold on to this frame, because it keeps paying off: most Spring features are just the container doing extra work while it assembles your beans.

The one rule that ties it together

All of this only works on objects the container built. The moment you build one yourself, none of it happens:

OrderService svc = new OrderService(); // built by you, not Spring
// no gateway injected — Spring never saw this object
Enter fullscreen mode Exit fullscreen mode

The usual symptom is a NullPointerException on a field you were sure Spring would fill in. Spring did not create the object, so it never wired it, and the field stayed null.

The same bypass quietly kills proxies. Call a @Transactional method on a hand-built instance, and there is no proxy around it, so no transaction ever starts. Nothing errors. The transaction simply isn't there. This is exactly why the bean-versus-new distinction from earlier mattered: Spring's powers ride on objects the factory made, and skip everything it didn't.

So the rule is short: if you want any of Spring's powers on an object, let Spring create the object. Don't new your beans.

Putting it together

Spring exists to take one job off your code: creating and connecting objects. Hand that job over, and three ideas fall out of it.

Inversion of Control is the handover itself — your code stops creating its own collaborators. Dependency injection is how those collaborators arrive, best through the constructor. And the container is the factory that does the work: it reads your recipes at startup, builds the objects in dependency order, caches them, and can quietly wrap each one in a proxy on the way out.

That last move, the proxy, is why so much of Spring feels like magic later. It isn't magic. It is the factory doing extra work as it builds your beans. Bean scopes, AOP, transactions — everything heavier you meet from here is a variation on this one startup pass.

Top comments (0)