Skip to main content

Tiny typed dependency injection for Python apps

Project description

Injex

Build Status pypi Docs Coverage Python Versions License

Tiny typed dependency injection for Python apps that want explicit wiring without a framework-sized container.

Injex is for the point where manual constructor calls are still readable in one place, but start repeating across an API, a worker, a CLI, and tests. It keeps wiring explicit: normal type hints, zero runtime dependencies, scoped lifetimes, test overrides, and graph validation before your app starts.

pip install injex

Website: vshulcz.github.io/injex

How it fits

Define one validated service graph at the composition root, then let every entrypoint resolve from it instead of re-wiring its own copy.

flowchart LR
  subgraph root["Composition root — one validated graph"]
    direction LR
    S[Settings] --> C[ApiClient]
    C --> R[UserRepository]
    C --> E[EmailSender]
    R --> U[RegisterUser]
    E --> U
  end
  root --> API[FastAPI]
  root --> CLI[Typer CLI]
  root --> WK[Worker]
  root --> TS[Tests]

The problem it solves

Without a composition root, the same object graph often leaks into every entrypoint:

repo = UserRepository(settings.database_url)
mailer = EmailSender(settings.smtp_url)
use_case = RegisterUser(repo, mailer)

That is fine once. It becomes harder to maintain when the API, background jobs, CLI commands, and tests all need the same graph with small differences.

With Injex, application code keeps normal constructors and startup code owns the wiring:

container = Container()
container.add_instance(Settings, settings)
container.add_singleton(UserRepository)
container.add_singleton(EmailSender)
container.add_transient(RegisterUser)

container.assert_valid()

use_case = container.resolve(RegisterUser)

Tests can replace one dependency without changing production registrations:

with container.override(EmailSender, instance=fake_mailer):
    use_case = container.resolve(RegisterUser)

Use Injex when

  • you have a service layer reused by an API, CLI, worker, and tests;
  • constructors already describe dependencies with type hints;
  • test doubles should replace external services without changing production wiring;
  • startup should catch missing registrations before the first request or job.

Skip Injex when

  • a few manual constructor calls are still clear enough;
  • your framework dependency system already covers every entrypoint;
  • you need a large provider/configuration DSL.

Why Injex?

  • Zero dependencies: pure Python, easy to vendor, audit, and run anywhere.
  • Typed constructor injection: dependencies are resolved from annotations.
  • Framework-agnostic: use the same wiring in web apps, workers, CLIs, and tests.
  • Production lifetimes: singleton, transient, and scoped services.
  • Factories and instances: use custom creation logic or prebuilt objects.
  • Named registrations: register multiple implementations of the same type.
  • Optional dependencies: Optional[T] works without special configuration.
  • Test overrides: swap real services for fakes in a small, explicit scope.
  • Container validation: catch missing annotations, missing registrations, and dependency cycles before your app starts.
  • Fast hot-path resolution: cached dependency plans keep repeated resolves close to manual wiring for small service graphs.

Performance snapshot

Injex compiles and caches simple dependency plans, then uses a fast path for common constructor-injection graphs. In a small synthetic graph with a singleton Settings, singleton ApiClient, and transient repository/service objects, Injex resolves faster than several popular Python DI containers on this machine.

Library Median resolve time
manual wiring 0.271 µs/op
Injex 0.401 µs/op
Wireup, same scope 0.910 µs/op
Wireup, scope per operation 1.605 µs/op
dependency-injector 1.776 µs/op
lagom 10.392 µs/op
punq 59.662 µs/op

Benchmarks are synthetic and not a universal ranking. They are included to show the approximate overhead of Injex in its target shape: small explicit service graphs reused by APIs, workers, CLIs, and tests.

Reproduce locally:

uv run --with punq --with lagom --with dependency-injector --with wireup \
  python benchmarks/resolve_graph.py

Where it fits

Injex is useful when manual wiring starts to spread across your entrypoints, but providers, global state, or a framework-specific container would be too much.

Common patterns:

  • Service layer: wire repositories, gateways, clients, and use cases once at startup.
  • CLIs: share configuration, API clients, and commands without module-level singletons.
  • Workers: create one scope per job or message while reusing long-lived clients.
  • Tests: override slow or external dependencies inside one with block.
  • Clean architecture: keep application code depending on interfaces instead of framework-specific dependency hooks.

Quick start

from injex import Container


class UserRepository:
    def save(self, email: str) -> int:
        return 42


class EmailSender:
    def send_welcome(self, email: str) -> None:
        print(f"Welcome, {email}")


class RegisterUser:
    def __init__(self, repo: UserRepository, email_sender: EmailSender):
        self.repo = repo
        self.email_sender = email_sender

    def execute(self, email: str) -> int:
        user_id = self.repo.save(email)
        self.email_sender.send_welcome(email)
        return user_id


container = Container()
container.add_singleton(UserRepository)
container.add_singleton(EmailSender)
container.add_transient(RegisterUser)

container.assert_valid()

use_case = container.resolve(RegisterUser)
user_id = use_case.execute("ada@example.com")

Validate wiring before startup

validate() checks the registered dependency graph without constructing your services. That makes it safe for startup checks and CI smoke tests.

errors = container.validate()

if errors:
    for error in errors:
        print(error)
    raise SystemExit(1)

Use assert_valid() when you prefer a single exception with all validation errors.

Testing with overrides

Use override() to replace a dependency only inside a with block.

class FakeEmailSender:
    def __init__(self):
        self.sent_to = []

    def send_welcome(self, email: str) -> None:
        self.sent_to.append(email)


fake_sender = FakeEmailSender()

with container.override(EmailSender, instance=fake_sender):
    use_case = container.resolve(RegisterUser)
    use_case.execute("test@example.com")

assert fake_sender.sent_to == ["test@example.com"]

Scopes for request-style lifetimes

Scoped services are reused inside one scope and recreated for another scope.

from injex import Container


class RequestContext:
    pass


container = Container()
container.add_scoped(RequestContext)

scope_a = container.create_scope()
scope_b = container.create_scope()

assert scope_a.resolve(RequestContext) is scope_a.resolve(RequestContext)
assert scope_a.resolve(RequestContext) is not scope_b.resolve(RequestContext)

Feature comparison

Feature Injex dependency-injector punq lagom
Zero runtime dependencies
Type-hint constructor injection
Singleton / transient lifetimes
Scoped lifetime
Named registrations
Property injection
Temporary test overrides
Graph validation without object creation
Small API surface

This table is not a benchmark. It shows the niche: Injex aims to be small and explicit while still covering common application wiring needs.

Documentation and examples

API at a glance

Method Use when
add_singleton(T, Impl) One instance should be reused for the app lifetime.
add_transient(T, Impl) A new instance should be created on every resolve.
add_scoped(T, Impl) One instance should be reused inside one scope.
add_*_factory(T, factory) Construction needs custom code.
add_instance(T, instance) You already have the object to use.
resolve(T) Resolve one service from the root container.
resolve_all(T) Resolve all unnamed implementations for a type.
create_scope() Start a request, job, or message lifetime.
override(T, ...) Temporarily replace a dependency in tests.
validate() / assert_valid() Check wiring before startup.

Common use cases

  • Service-layer wiring in web APIs without coupling code to a web framework.
  • Clean architecture use cases with repositories, gateways, and presenters.
  • CLI tools where commands share configuration, clients, and services.
  • Background workers and consumers with per-job or per-message scopes.
  • Unit tests that need explicit dependency replacement.

Contributors

Thanks to the people improving Injex through issues, reviews, and pull requests:

Contributing

Contributions are welcome when they keep the API small, tested, and practical. Useful changes usually improve documentation, typing, examples, or narrow edge cases without adding runtime dependencies.

See CONTRIBUTING.md for the local setup and contribution guidelines.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

injex-1.4.0.tar.gz (30.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

injex-1.4.0-py3-none-any.whl (17.4 kB view details)

Uploaded Python 3

File details

Details for the file injex-1.4.0.tar.gz.

File metadata

  • Download URL: injex-1.4.0.tar.gz
  • Upload date:
  • Size: 30.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for injex-1.4.0.tar.gz
Algorithm Hash digest
SHA256 40a7d25644964e20cfff4499fe053e20c0120598e3527ab74e5d14443f1c1fb9
MD5 380327182a8aaed4b19c689ec6e39e04
BLAKE2b-256 c148b51360cb0f5d6b71f0db7672d8681d800d862499708994eca051849b0242

See more details on using hashes here.

Provenance

The following attestation bundles were made for injex-1.4.0.tar.gz:

Publisher: release.yml on vshulcz/injex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file injex-1.4.0-py3-none-any.whl.

File metadata

  • Download URL: injex-1.4.0-py3-none-any.whl
  • Upload date:
  • Size: 17.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for injex-1.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f591f5aa58746d1469d11d1f0b5f2b1dc2a69f0115da21f5951a2d41c22ddaae
MD5 1bfaec51199c3db0fb4ce4910bbbd5a3
BLAKE2b-256 cf64d35655747d35d225320d7906a0a881c95f69ca3c61564d29c8554f481dfd

See more details on using hashes here.

Provenance

The following attestation bundles were made for injex-1.4.0-py3-none-any.whl:

Publisher: release.yml on vshulcz/injex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page