Two months ago I did not know what an endpoint was. So when I first read that
Depends is FastAPI's dependency injection, I took it at face value, used it
everywhere, and assumed I was following a principle.
I was following about half of one. This post is what I understand now, written mostly for the version of me who was confident about it a month ago.
Two things with similar names
Dependency Injection is a pattern: an object receives what it needs from outside instead of building it itself.
The Dependency Inversion Principle is the D in SOLID, and it says something stronger: depend on an abstraction, not on a concrete implementation.
The first is about where the object comes from. The second is about what your code is allowed to know about it. You can have DI without DIP — and most FastAPI code does.
Here is the shape everyone starts with:
class TokenChecker:
def __init__(self) -> None:
self.storage = Redis()
No injection at all. TokenChecker builds its own dependency, so it cannot be
tested without a Redis and cannot be moved to anything else.
Passing it in fixes the first half:
class TokenChecker:
def __init__(self, storage: Redis) -> None:
self.storage = storage
That is dependency injection. It is genuinely better — you can hand it a fake
in a test. But TokenChecker still names Redis in its own signature, so the
class still knows what it is talking to.
Naming an abstraction instead is the inversion:
class Storage(Protocol):
def get(self, token: str) -> str | None: ...
class TokenChecker:
def __init__(self, storage: Storage) -> None:
self.storage = storage
Now the class describes what it needs, and something else decides what satisfies it.
So what is Depends?
It is dependency injection. It is not dependency inversion.
DbSession = Annotated[AsyncSession, Depends(get_db)]
The session arrives from outside — FastAPI calls get_db per request and hands
the result in. That is injection, and it does real work: the session's lifetime
is managed for me, and in tests I replace it through dependency_overrides
without touching a single route.
But the route names get_db, a specific function. There is no abstraction
between them. Swapping the implementation means overriding that exact callable
by identity, which is why dependency_overrides is documented as a testing
tool rather than an architectural one.
There is a stronger claim floating around — that Depends is not DI at all,
just sugar over calling the function yourself. I do not think that holds: the
override mechanism is a genuine seam, and calling the function directly gives
you no seam whatsoever. But "DI, not DIP" is fair, and it is the more useful
way to say it, because it tells you what is missing rather than arguing about a
label.
Where I actually needed the inversion
Only once, and not because I wanted swappable implementations.
The password service issues a reset link and has to arrange for it to reach
somebody. The code that sends mail lives in the presentation layer. My layers
only allow imports downward or sideways, and services importing
presentation points the wrong way — a rule enforced by a test that walks the
real import graph, so this is not a preference I could quietly ignore.
Without an abstraction there is no legal way to write it. With one:
class ResetMailer(Protocol):
def __call__(self, *, to_email: str, username: str, token: str) -> None: ...
async def request_reset(db: AsyncSession, email: str, mailer: ResetMailer) -> None:
...
mailer(to_email=user.email, username=user.username, token=token)
The service names a shape. The route passes something that fits. Structural typing means the implementation never imports the protocol — so the arrow points one way and stays there.
That is the real reason to invert a dependency, and it is worth separating from the reason usually given. It was not "I might want a different mailer someday." It was "this import is illegal, and an abstraction is what makes the dependency legal."
Note also how small the protocol is. An address, a name, a token, no return value. The service knows an email is sent and nothing about SMTP, wording, or whether it happens in the background. That narrowness is the I in SOLID doing its job: a wide interface would have dragged the sender's whole world into the service's vocabulary.
Where I wrote an interface and deleted it
The avatar code got the same treatment first — a protocol over storage, S3 behind it, local disk maybe later.
I deleted it, because I could not answer one question: what would the second implementation look like? Local disk has no bucket, no content type, no presigned URLs. Its shape would differ enough that a protocol written from S3's shape would need rewriting anyway.
That is the tell. The interface was not derived from what callers need — it was
derived from what one library happened to offer. With a single implementation it
decoupled nothing; it described AWSAvatars twice, in two files that had to be
edited together.
So the storage is used concretely, and the module says so out loud:
This is the only storage the application uses, and no other is planned, so
AWSAvatarsis used directly — no protocol standing between them.
A deleted abstraction with no note looks like something nobody considered. The note is what turns it into a decision.
Two things Depends does not do
Worth knowing before you decide it is enough.
There is no application scope. Every Depends runs per request. Things that
should exist once — a connection pool, an expensive constant — have to live
somewhere else. In my case that is module-level globals created from lifespan:
engine: AsyncEngine | None = None
def setup_engine() -> None:
global engine
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
It works, and for one engine it is honest. At five or six such objects it turns
into global statements scattered across modules with no single place
responsible for tearing them down.
Cleanup of those objects is yours. Depends with yield handles per-request
teardown well. Application-level teardown is a lifespan you write by hand.
Both of these are the reason DI container libraries exist for FastAPI. They give you scopes, one place for the object graph, and a container reusable outside the HTTP layer — which matters the moment a worker or a CLI needs the same objects.
I have not used one here. Three application-level objects and one inverted
dependency do not justify a container; it would be a second mechanism doing what
lifespan already does. The point at which I would reach for one is specific
rather than aesthetic: when the same graph has to be assembled somewhere that
is not a request.
What I would tell myself a month ago
Dependsis DI. It is not DIP. Both statements are useful; only the second one tells you what you are missing.- Introduce an interface when there are already two implementations, or when the dependency would otherwise point in a direction your architecture forbids.
- "Might need to swap it someday" is neither of those. It is a prediction, paid for in a file you maintain until it comes true.
- Write down the decision either way. The protocol should say why it exists, and the code without one should say why it does not.
The principle was never "always depend on abstractions." It was "notice which direction your dependencies point, and invert the ones pointing the wrong way." Most of them are pointing the right way already.