Fast dependency injection in Python without a provider framework
Source: Dev.to
Python does not need a dependency injection container by default. 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? 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 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. 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. I built Injex for that middle ground. Not a full provider framework. 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. 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. 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).
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 \
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. 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. 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