Depends is dependency injection.But it isn't dependency inversion (or DIP) within SOLID.

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 AWSAvatars is 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

  • Depends is 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.

All posts