Fast dependency injection in Python without a provider framework

Published: (June 9, 2026 at 03:54 AM EDT)
4 min read
Source: Dev.to

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

0 views
Back to Blog

Related posts

Read more »