"Dependency injection" is one of those terms that sounds like it needs a framework, a container, and an afternoon of reading. It doesn't. Strip away the jargon and it is a single, almost boring habit: don't let a class build the things it depends on - hand them to it instead. It is the quiet mechanism behind advice you have probably already heard, like favouring composition over inheritance: assemble an object from the parts you give it, rather than the ones it hard-codes. That one change is what separates code you can test and evolve from code that fights you the moment requirements move.
Let me show you the exact point where the "build your own" style breaks, with code.
It always starts innocent
Say we have an OrderService that places an order and emails the customer a confirmation. The obvious first version builds everything it needs, right where it needs it:
class OrderService:
def __init__(self):
self.email = SmtpEmailClient(host="smtp.myapp.com", port=587)
def place_order(self, order):
save_to_db(order)
self.email.send(
to=order.customer_email,
subject="Your order is confirmed",
body=f"Thanks! Order #{order.id} is on its way.",
)
return orderNothing looks wrong. OrderService needs to send email, so it makes an email client. This is the version that ships, and for a while it is completely fine.
The moment it bites you
Then you sit down to write a test for place_order, and the trouble shows up immediately.
def test_place_order_confirms_the_customer():
service = OrderService()
service.place_order(order) # this just sent a real emailThere is no seam. OrderService reached into its own constructor and wired itself to a live SMTP server, so you cannot exercise the method without actually sending mail to a real inbox. To test the ordering logic, you first have to defeat the emailing logic, and the class has given you no way in.
The same rigidity hurts in production too. Marketing wants to move from SMTP to a transactional email API. Now you are editing OrderService - a class that has nothing to do with how email is delivered - just to swap the delivery mechanism. And every other class that builds its own SmtpEmailClient has to be found and edited too.
This is the wall. A class that constructs its own dependencies is welded to those exact concrete choices. You cannot substitute, fake, or reconfigure them without opening the class up and rewiring it.
Inject the dependency
The fix is almost embarrassingly small. Instead of building the email client, ask for one:
class OrderService:
def __init__(self, email_client):
self.email = email_client
def place_order(self, order):
save_to_db(order)
self.email.send(
to=order.customer_email,
subject="Your order is confirmed",
body=f"Thanks! Order #{order.id} is on its way.",
)
return orderThat's it. That's dependency injection. The class no longer decides which email client it uses; whoever creates the OrderService decides and passes it in:
service = OrderService(SmtpEmailClient(host="smtp.myapp.com", port=587))The concrete choice moved up, to the place that assembles the app, and out of the class that just wants to send a message. And look at what the test can do now:
class FakeEmailClient:
def __init__(self):
self.sent = []
def send(self, to, subject, body):
self.sent.append((to, subject))
def test_place_order_confirms_the_customer():
email = FakeEmailClient()
service = OrderService(email)
service.place_order(order)
assert email.sent == [(order.customer_email, "Your order is confirmed")]No real inbox, no network, no SMTP server. You handed the class a stand-in and then asked it a plain question: did you try to confirm the customer? The seam you were missing is now just a constructor argument.
Depend on an abstraction, not a concrete class
There is one idea worth making explicit, because it is the part people quote and rarely explain. OrderService no longer cares whether it received an SmtpEmailClient, a SendGridClient, or a FakeEmailClient. It only cares that the thing it was given has a send method. In Python, a Protocol lets you name that contract:
from typing import Protocol
class EmailClient(Protocol):
def send(self, to: str, subject: str, body: str) -> None: ...Now OrderService depends on the idea of an email client, and every concrete client is just one implementation of that idea. High-level policy ("confirm the customer when an order is placed") stops depending on a low-level detail ("we use SMTP"). Both now lean on the shared abstraction in the middle.
If that sounds like a principle, it is - this is the "D" in SOLID, the Dependency Inversion Principle. But you don't need to memorise the letter to feel the point: the code you care about should describe what it wants done, not which gadget does it.
What actually got better
The class became testable. You can hand it a fake and assert on behaviour without touching the network. The logic you wrote is finally reachable in isolation - the same quality that makes pure functions so easy to test.
Swapping an implementation stops touching the class. Moving from SMTP to an API is a change at the assembly point, not surgery inside OrderService. The class that owns the business rule never reopens to change the delivery mechanism - the same open/closed win the Strategy pattern buys you: open to extension, closed to modification.
Dependencies stop hiding. A constructor that lists what a class needs is honest. You can read OrderService(email_client) and know, at a glance, that this thing talks to email. A class that secretly builds its own collaborators hides that from you until you read every line.
One collaborator can serve many callers. Because the client is created once, up top, and passed around, you configure it in a single place instead of scattering SmtpEmailClient(...) through the codebase.
You don't need a framework for this
Here is the myth worth killing: dependency injection is not a library, and it is not a "container." What we just did - pass a thing in through the constructor - is dependency injection, in full. The Java and C# worlds often reach for a DI container that wires objects together automatically, and in a large app that can earn its keep. But the container is an optional convenience for doing injection at scale, not the thing itself. In plenty of codebases, "pass it in as an argument" is the entire technique, and it is enough.
When you don't need it
I want to be honest, because injecting everything on principle is its own kind of mess.
Don't inject stable, pure helpers. If a class uses json.dumps or a bit of math, leave it. There is no benefit to faking a deterministic, dependency-free function - you would add a seam nobody will ever use.
Inject at the edges, not everywhere. The dependencies worth injecting are the ones that reach the outside world or that you will want to swap or fake: databases, HTTP clients, email, clocks, payment gateways, the file system. A small value object that just holds data does not need this ceremony.
A throwaway script can build its own tools. If the code will never be tested and never grow, the tight coupling costs you nothing. Reach for injection when a dependency is slow, external, or likely to change - which, for anything that outlives the afternoon, it usually is.
The takeaway
Dependency injection is not the grand, framework-shaped thing its name makes it sound. It is one habit: when a class needs something, let it ask for that thing instead of building it. Do that and two problems dissolve at once - your code becomes testable, because you can pass in a fake, and it becomes flexible, because you can pass in anything. No container required. Just move the new out of the class and up to the place that assembles your app, and let each piece describe what it wants rather than reaching for it.

