DEV Community

Cover image for Fast dependency injection in Python without a provider framework
Vlad Shulcz
Vlad Shulcz

Posted on

Fast dependency injection in Python without a provider framework

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)
Enter fullscreen mode Exit fullscreen mode

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),
    )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Workers and CLIs can use the same builder directly:

services = build_services(load_settings())
services.register_user.execute("ada@example.com")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Application classes stay plain:

class RegisterUser:
    def __init__(self, repo: UserRepository, email: EmailSender):
        self.repo = repo
        self.email = email
Enter fullscreen mode Exit fullscreen mode

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:

  1. cleaner internals;
  2. 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)

Collapse
 
vshulcz profile image
Vlad Shulcz •

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?