Python does not need a dependency injection container by default.
For small apps, direct constructor calls are clearer than any framework:
repo = UserRepository(settings.database_url)
email = EmailSender(settings.smtp_url)
use_case = RegisterUser(repo, email)
That is fine once.
The problem starts when the same object graph appears in more than one place:
- FastAPI startup;
- Typer commands;
- background workers;
- scripts;
- tests.
At that point, the question is not "how do I inject everything?"
The better question is:
Where should application wiring live?
The boundary I want
For web apps, I like this split:
- FastAPI owns HTTP adaptation;
- Typer owns CLI adaptation;
- workers own job adaptation;
- the application owns service wiring.
That means repositories, gateways, and use cases should depend on normal Python types, not framework primitives.
A service should not need to know whether it was called from an HTTP request, a CLI command, a queue worker, or a test.
FastAPI Depends is not the whole composition root
FastAPI Depends is excellent at the request boundary.
It handles request data, authentication, headers, cookies, and per-request adapters.
But if the same service graph is used outside HTTP, the real composition root should live in plain Python.
A useful shape is:
def build_services(settings: Settings) -> Services:
client = ApiClient(settings)
repo = UserRepository(client)
email = EmailSender(client)
return Services(
register_user=RegisterUser(repo, email),
)
FastAPI can adapt that graph:
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.services = build_services(load_settings())
yield
def get_register_user(request: Request) -> RegisterUser:
return request.app.state.services.register_user
Workers and CLIs can use the same builder directly:
services = build_services(load_settings())
services.register_user.execute("ada@example.com")
The rule of thumb:
FastAPI adapts HTTP. The application owns service wiring.
Start with factories
I still think plain factories are the best default.
If your graph is small, this is better than adding any container:
def build_register_user(settings: Settings) -> RegisterUser:
client = ApiClient(settings)
repo = UserRepository(client)
email = EmailSender(client)
return RegisterUser(repo, email)
A DI container becomes useful later, when the same graph starts repeating across entrypoints and tests.
Why I built Injex
I built Injex for that middle ground.
Not a full provider framework.
Not framework-specific dependency injection.
Not a replacement for FastAPI Depends.
The niche is small Python apps that want:
- explicit registrations;
- constructor injection from type hints;
- singleton, transient, and scoped lifetimes;
- test overrides;
- graph validation before startup;
- zero runtime dependencies.
Example:
from injex import Container
container = Container()
container.add_instance(Settings, settings)
container.add_singleton(ApiClient)
container.add_transient(UserRepository)
container.add_transient(EmailSender)
container.add_transient(RegisterUser)
container.assert_valid()
use_case = container.resolve(RegisterUser)
Application classes stay plain:
class RegisterUser:
def __init__(self, repo: UserRepository, email: EmailSender):
self.repo = repo
self.email = email
No decorators required for constructor injection.
No provider DSL.
No runtime dependencies.
What changed in 1.3.0
Injex 1.3.0 focused on two things:
- cleaner internals;
- faster repeated resolves.
Internally, the package is now split into focused modules:
- container.py;
- planning.py;
- registry.py;
- errors.py.
For performance, Injex now caches dependency plans and uses a fast path for common constructor-injection graphs.
Benchmark
I added a reproducible benchmark for a small service graph:
- singleton Settings;
- singleton ApiClient(settings);
- transient UserRepository(client);
- transient EmailSender(client);
- transient AuditLog(settings);
- transient RegisterUser(repo, email, audit).
Local result
| Library | Median resolve time |
|---|---|
| manual wiring | 0.265 µs/op |
| Injex | 0.818 µs/op |
| Wireup, same scope | 0.879 µs/op |
| Wireup, scope per operation | 1.559 µs/op |
| dependency-injector | 1.727 µs/op |
| lagom | 9.794 µs/op |
| punq | 56.795 µs/op |
This is not a universal ranking.
Different graphs, lifetimes, async resources, framework integrations, and request scope models can change results.
The benchmark exists to answer a narrower question:
Can explicit typed wiring stay small and fast?
For this graph, yes.
Reproduce it:
uv run --with punq --with lagom --with dependency-injector --with wireup \
python benchmarks/resolve_graph.py
When I would not use Injex
I would skip Injex when:
- a few constructor calls are still clear;
- a framework dependency system covers every entrypoint;
- the app needs a large provider/configuration DSL;
- the team does not want a container at all.
Manual wiring is still the baseline.
Where Injex fits
I would consider Injex when:
- a service layer is reused by API, CLI, workers, and tests;
- constructors already describe dependencies with type hints;
- tests need temporary external-service overrides;
- startup should catch missing registrations before first request/job;
- the team wants explicit wiring without a large DI framework.
Links
Repo:
https://github.com/vshulcz/injex
Docs:
https://vshulcz.github.io/injex/
Performance notes:
https://vshulcz.github.io/injex/docs/performance.html
Compared to FastAPI Depends:
https://github.com/vshulcz/injex/blob/main/docs/fastapi-depends.md
Top comments (1)
I’m interested in how other Python teams draw this boundary.
Do you keep service wiring as plain factories, use FastAPI dependencies everywhere, or use an external container?