Quick Answer

A DI container builds your application's object graph for you by inspecting each class's constructor, recursively resolving and constructing whatever types it asks for. Spring does this with Java reflection and a dedicated bean post-processor; NestJS and Angular do it by reading compiler-emitted constructor-type metadata (design:paramtypes) because JavaScript has no native runtime type reflection. Either way, the container also has to solve object lifetime (singleton vs. fresh-every-time), which a naive implementation gets wrong by default.

The Problem: Someone Has to Build the Object Graph

Any application past a trivial size ends up with objects that depend on other objects. A UserService needs a UserRepository, which needs a Database connection, which needs a Logger. Without any framework, someone has to write the code that constructs all of this in the right order: build the Logger first, pass it into a new Database, pass that into a new UserRepository, pass that into a new UserService. Do this by hand across a real application with dozens of services and it turns into a sprawling mess of constructor calls, and worse, every class ends up hard-wired to concrete implementations of its dependencies, which makes swapping a real database for a test double, or a real email sender for a mock, a manual rewiring exercise instead of a config change.

A dependency injection container (also called an IoC container, for "Inversion of Control") is software whose entire job is to do that wiring for you. You tell it what classes exist and, optionally, which concrete implementation satisfies which interface, and it figures out the construction order and builds the object graph automatically — usually by inspecting each class's constructor to see what it asks for.

Building a Real One in About 20 Lines of Python

The core mechanism is less magical than it sounds, and it's small enough to actually build and run. The container needs to look at a class's constructor, read what types each parameter expects, and recursively resolve each of those types the same way — building dependencies bottom-up before the thing that needs them. Python's inspect module and type hints make this straightforward:

import inspect
from typing import get_type_hints

class Container:
    def __init__(self):
        self._registry = {}

    def resolve(self, cls):
        target = self._registry.get(cls, cls)
        sig = inspect.signature(target.__init__)
        hints = get_type_hints(target.__init__)
        kwargs = {}
        for name, param in sig.parameters.items():
            if name == "self":
                continue
            dep_type = hints.get(name)
            if dep_type is None:
                continue
            kwargs[name] = self.resolve(dep_type)  # recurse
        return target(**kwargs)

Running this against a real 4-level dependency chain — UserService needs a UserRepository and a Logger; UserRepository needs a Database and a Logger; Database needs a Logger — produces this actual output when you call container.resolve(UserService) with print statements tracing each step:

Resolving Logger -> needs []
  constructed Logger()
Resolving Database -> needs ['logger']
  constructed Database(logger)
Resolving Logger -> needs []
  constructed Logger()
Resolving UserRepository -> needs ['db', 'logger']
  constructed UserRepository(db, logger)
Resolving Logger -> needs []
  constructed Logger()
Resolving UserService -> needs ['repo', 'logger']
  constructed UserService(repo, logger)

It works — the whole graph gets built with zero manual wiring. But look closely at how many times Logger gets constructed: three separate times. Checking whether service.logger and service.repo.db.logger are the same object with Python's is operator returns False. That's a real bug a naive container has, not a hypothetical one, and it's exactly the kind of thing that makes DI containers more than "just call the constructor for me."

Why Every Real Container Has to Solve Object Lifetime

The naive container above treats every resolve() call as "build a fresh instance," which is a real lifetime strategy (usually called transient scope) and is sometimes exactly what you want — but it's rarely what you want for something like a database connection pool or a logger, where you want one shared instance reused everywhere. Fixing this just requires caching by type, resolving once and handing back the same object on every subsequent request for that type:

class Container:
    def __init__(self):
        self._singletons = {}

    def resolve(self, cls):
        if cls in self._singletons:
            return self._singletons[cls]
        sig = inspect.signature(cls.__init__)
        hints = get_type_hints(cls.__init__)
        kwargs = {
            name: self.resolve(hints[name])
            for name, param in sig.parameters.items()
            if name != "self" and name in hints
        }
        instance = cls(**kwargs)
        self._singletons[cls] = instance
        return instance

Re-running the exact same object graph through this version and checking service.logger is service.repo.db.logger now actually returns True — same instance, everywhere, because the cache intercepts the second and third requests for a Logger before a new one gets constructed. This is precisely why real frameworks expose explicit lifetime/scope options: Spring calls these bean scopes (singleton is the default, with prototype, request, and session also available); .NET's container has Singleton, Scoped, and Transient; Angular's injectors have a similar singleton-per-injector default. It isn't a framework being fussy — it's a genuinely necessary decision the naive version above gets wrong by default.

How Spring Actually Does This at Framework Scale

Spring's real implementation is the same core idea as the toy container above, scaled up with more infrastructure. On startup, Spring scans the classpath (driven by @ComponentScan) for classes annotated @Component, @Service, @Repository, and similar, and for each one it creates a bean definition — metadata describing how to build that object, not the object itself yet. These definitions get registered with a bean factory (typically DefaultListableBeanFactory), which is the actual engine responsible for instantiating and wiring beans.

The @Autowired annotation is handled by a specific piece of infrastructure called AutowiredAnnotationBeanPostProcessor, which runs during bean creation, uses Java reflection to find fields, constructors, or setter methods marked @Autowired, resolves the matching bean for each required type from the application context (falling back to matching by name if multiple candidates implement the same interface), and — for field injection specifically — calls field.setAccessible(true) to bypass Java's normal private-field access rules before setting the value directly via reflection. This is the same fundamental technique as the Python example above ("look at what a constructor or field needs, go get it, inject it") implemented with Java's reflection API and wrapped in a lot more lifecycle management: bean post-processors, @PostConstruct/@PreDestroy hooks, proxy creation for AOP features like @Transactional, and circular-dependency detection that throws a clear error instead of infinite-looping the way a naive recursive resolver would.

NestJS and Angular: Same Idea, Different Mechanism

TypeScript frameworks can't use the same reflection trick Java does, because by default JavaScript erases type information at compile time — there's no runtime way to ask "what type is this constructor parameter" the way Python's get_type_hints or Java's reflection API can. NestJS and Angular solve this with a specific compiler feature: when a TypeScript project enables emitDecoratorMetadata in tsconfig.json, the compiler emits extra metadata alongside any decorated class, including the types of its constructor parameters, stored under a design:paramtypes key. This metadata is read at runtime using the reflect-metadata library (a polyfill, since JavaScript has no native reflection metadata API).

So when you write an @Injectable() class in NestJS with a constructor like constructor(private db: Database), the DI container doesn't inspect the live object at runtime the way Spring does — it reads the design:paramtypes metadata the compiler already baked in, sees Database listed as parameter 0's type, and resolves and injects accordingly. Angular's injector does the equivalent lookup via Reflect.getMetadata("design:paramtypes", SomeService). The practical consequence: forget to enable emitDecoratorMetadata, or use an interface instead of a class as a constructor parameter type (interfaces are erased completely at compile time, even with the metadata flag on, since they have no runtime representation at all), and the container has nothing to resolve against — which is why NestJS requires an explicit @Inject('SOME_TOKEN') decorator for anything that isn't a concrete class.

The Failure Modes Worth Knowing Before You Hit Them

A few problems show up repeatedly once you're actually using a DI container rather than just reading about one:

  • Circular dependencies: if A's constructor needs B and B's constructor needs A, a naive recursive resolver (like the first toy container above) will recurse forever and stack-overflow. Real containers detect the cycle and throw a clear error, and the usual fixes are constructor-to-setter injection for one side of the cycle, or restructuring so the circular relationship doesn't need to exist.
  • Ambiguous resolution: if two classes both implement the same interface and nothing disambiguates which one a given injection point wants, the container can't guess. This is why Spring has @Qualifier and NestJS has named injection tokens — explicit disambiguation when type alone isn't enough.
  • Lifetime mismatches: injecting a short-lived (request-scoped) object into a long-lived singleton silently captures a stale reference past its intended lifetime, which is a subtle bug class specific to DI containers that manual wiring doesn't have, because manual code tends to make lifetime explicit by where objects get constructed.

Frequently Asked Questions

What's the difference between dependency injection and a dependency injection container? Dependency injection is the pattern of passing a class's dependencies in from outside rather than having it construct them itself. A DI container is a specific piece of software that automates that passing-in by inspecting constructors and building the object graph for you.
Why does Spring need reflection to do dependency injection? Spring doesn't know your classes' dependency structure until runtime, since it's scanning arbitrary application code rather than code written against a fixed API. Reflection lets it inspect any class's constructor and fields at runtime, even private ones, to figure out what to inject.
Why can't NestJS use the same reflection approach as Spring? JavaScript, unlike Java, erases type information at compile time by default, so there's no runtime type to reflect on. NestJS works around this using the emitDecoratorMetadata TypeScript compiler flag, which bakes constructor parameter types into metadata the reflect-metadata library can read back at runtime.
What happens if two classes implement the same interface in a DI container? The container can't automatically decide which one you want from type alone, so it needs explicit disambiguation — Spring's @Qualifier annotation or a named injection token in NestJS/Angular are the standard ways to resolve the ambiguity.
Is a DI container the same thing as a service locator? No, and the distinction matters. A service locator is called explicitly from inside your code to fetch a dependency on demand, while a DI container pushes dependencies into your constructor before your code runs at all, which is why it's called 'inversion of control' — control over construction is inverted from your class to the container.