A function that sends a welcome email looks harmless: SmtpClient().send(user, "hi"). Then you write the unit test, and the test tries to open a socket. I ran exactly that case, and the output was ConnectionError: no SMTP in tests. The function built its own collaborator, so the test had no way to say "use a fake one instead".
That is the entire motivation for dependency injection, and it fits in one sentence: an object receives the things it needs instead of creating them. My claim in this issue is that the pattern and the tooling around it are two separate things, and most of the pain people associate with DI belongs to the tooling. You can have all the benefits with zero frameworks, and you can have a heavy framework and none of the benefits.
The pattern, with the framework removed
Martin Fowler gave the technique its name in a 2004 article, Inversion of Control Containers and the Dependency Injection pattern. Read it again and you notice that the pattern itself takes up a few paragraphs. The rest is about containers, and he treats them as an optional convenience.
Here is the welcome-email function rewritten so the dependency is a constructor argument:
from dataclasses import dataclass
from typing import Protocol
class Mailer(Protocol):
def send(self, to: str, body: str) -> None: ...
class FakeMailer:
def __init__(self): self.sent = []
def send(self, to, body): self.sent.append((to, body))
@dataclass
class Signup:
mailer: Mailer
def run(self, email):
self.mailer.send(email, "welcome")
return "ok"
fake = FakeMailer()
print(Signup(fake).run("[email protected]"), fake.sent)
# ok [('[email protected]', 'welcome')]
There is no framework in that snippet, no decorator, no registration step. Signup declares what it needs, and whoever constructs it decides what to hand over. In production that is an SMTP client. In a test it is a list that records calls. The class does not know and does not care.
The Spring documentation describes the same split: classes declare constructor arguments or setters, and the container supplies them. The same page says the Spring team generally advocates constructor injection, because it lets you implement components as immutable objects and ensures required dependencies are not null. Those two properties are the real payoff, and neither needs Spring.
Why constructor injection beats the alternatives
There are three places to inject a dependency: the constructor, a setter, or a field. Field injection is the one that looks tidiest, because an annotation on a private field makes the dependency appear without any plumbing. It is also the worst, because the only way to populate that field outside the container is reflection. The test you wanted to write without a framework now needs the framework.
Setter injection has a different cost. It makes every dependency optional by construction, so an object can exist in a half-wired state. You find out at the first call that touches the missing collaborator, which is usually in production and usually at an inconvenient hour.
Constructor injection makes a half-wired object impossible. If Signup needs a mailer, you cannot instantiate it without one. A side effect I value more than the type safety: the constructor signature becomes an honest list of what the class talks to. When that list grows to nine arguments, the class is telling you something about its design. A container that auto-wires nine dependencies silently hides the same signal.
The service locator is not the same thing
The usual shortcut people take when DI feels like ceremony is a global registry that code calls into: Locator.get("mailer"). Fowler discusses this alternative in the same article, and it works. Where it falls apart is visibility. Compare what the two signatures say, as printed by inspect.signature in my test script:
class Locator: registry = {"mailer": fake}
def signup_locator(email): Locator.registry["mailer"].send(email, "w")
def signup_injected(email, mailer: Mailer): mailer.send(email, "w")
# signup_locator -> (email)
# signup_injected -> (email, mailer: Mailer)
The first signature claims the function needs an email address and nothing else. It actually needs a configured global, and the compiler, the reader and the test author find that out one at a time, by failure. The second signature is a complete statement of the function's needs. Nothing is hidden, and you can call it from a REPL with a fake in one line.
Where DI actually breaks in production
Everything above is the easy part. The failures I have seen come from the container, and they come in three kinds.
Captive dependencies. A long-lived object holds a reference to something that should have been short-lived. The Microsoft docs on dependency injection in .NET say a scoped service created in the root container is effectively promoted to a singleton, and that the default provider, in the development environment, validates that scoped services are not injected into singletons. I reproduced the shape of the bug without any framework:
repo_singleton = Repo(Session())
ids = [repo_singleton.session.id for _ in range(3)]
ids_scoped = [Repo(Session()).session.id for _ in range(3)]
# captive: [1, 1, 1] scoped: [2, 3, 4]
The singleton captured the first session and kept it for the life of the process. In a real service that session carries a database connection, a tenant ID or an authenticated user, and every request after the first reuses it. This is how one customer sees another customer's data, and no type checker will flag it, because every object has the right type. The fix is a lifetime rule that a reviewer can check: an object may only depend on objects with an equal or longer lifetime. The .NET default provider does that check only in development by default, so make sure your staging and production configuration validates too, or that a test builds the provider with validation on.
Cycles, found at runtime. If A needs B and B needs A, constructor injection cannot build either. With hand-wired code you hit that at compile time or at the first line of main. With a container it surfaces when the object is first requested, which may be a rarely used code path. I wrote a twenty-line container to see what a decent error looks like, and the useful part was tracking the resolution stack:
class Container:
def __init__(self): self._f = {}; self._cache = {}; self._stack = []
def register(self, name, factory): self._f[name] = factory
def get(self, name):
if name in self._cache: return self._cache[name]
if name in self._stack:
raise RuntimeError("cycle: " + " -> ".join(self._stack + [name]))
self._stack.append(name)
try: obj = self._f[name](self)
finally: self._stack.pop()
self._cache[name] = obj
return obj
# RuntimeError: cycle: a -> b -> a
The error names the whole chain, which is what you need at 2 a.m. A cycle is almost never a wiring problem. It means two classes each know too much about the other, and the right response is to extract the shared piece into a third class, not to reach for setter injection or lazy proxies to hide it.
Magic that moves failures to runtime. Reflection-based containers resolve dependencies by scanning types and annotations. That is convenient until a missing binding turns into an exception during the first request instead of a failure at build time. Dagger describes itself as a fully static, compile-time dependency injection framework, and the point of that design is to resolve the graph before the program runs rather than during a request. I did not run Dagger for this issue, so check its documentation for exactly which wiring errors fail the build. The trade is less runtime flexibility in exchange for finding wiring mistakes earlier.
What a container is actually for
A container does two jobs. It builds the object graph, and it manages lifetimes. If your application has twenty classes, you do not need either done for you. A single function at the program entry point, usually called the composition root, can construct everything in order:
def build_app(config):
mailer = SmtpMailer(config.smtp_url)
repo = UserRepo(config.db_url)
return Signup(mailer), Billing(repo, mailer)
That function is the whole wiring layer. It is plain code, so your debugger, your IDE's "find usages" and your type checker all work on it.
The calculus changes when the graph is large (hundreds of classes), when you need per-request scopes, or when many teams contribute components that must be assembled without a central file everyone edits. At that scale the composition root becomes a merge-conflict magnet and a container pays for itself. The honest rule is that a container is a scaling tool for wiring, the same way a build system is a scaling tool for compilation.
The yes, but
The strongest objection is that explicit wiring does not scale, and that I am describing a toy. That is partly right. Past a certain size, hand-written factories become boilerplate that nobody wants to maintain, and containers do remove real drudgery. I also have not measured startup cost: reflection-heavy containers can add noticeable time to boot, and I did not benchmark any, so I will not put a number on it.
Where the objection fails is the order of operations. Teams reach for the container first and learn the pattern second, and then they get the container's failure modes (captive dependencies, runtime cycles, invisible wiring) without the benefit the pattern was supposed to deliver. Learn the constructor-argument version until it feels boring. Adopt a container when the composition root hurts, and pick one that validates the graph at startup or at compile time.
One more caveat on the code in this issue: the Python snippets above were executed and the outputs shown are real. The Spring and .NET claims come from their documentation and I did not run those frameworks.
What to do on Monday
Pick one class in your codebase that is hard to test and read its constructor and its method bodies. Every new, every global call and every static accessor inside a method is a hidden dependency. Move each one into a constructor parameter, give it an interface only if you have a second implementation or a test double that needs one, and leave the container out of it for now.
Then answer one question in your next design review: what is the lifetime of each object in this graph, and does anything long-lived hold something short-lived? If nobody can answer, that is the bug you have not hit yet.

