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

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 1.3.0 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.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

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.3.0.tar.gz (25.3 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.3.0-py3-none-any.whl (15.2 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for injex-1.3.0.tar.gz
Algorithm Hash digest
SHA256 6a0fcad130aab7c64f80c6d833c6e4fa08dee65d8d204d4b12206c3cdc7320cd
MD5 27d3af1e46553fafe80d4a5bd30cc876
BLAKE2b-256 56a0702241567943fccb1bdfd8c9c8ce98e5eef8c8db738a1669735aa75ff92d

See more details on using hashes here.

Provenance

The following attestation bundles were made for injex-1.3.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.3.0-py3-none-any.whl.

File metadata

  • Download URL: injex-1.3.0-py3-none-any.whl
  • Upload date:
  • Size: 15.2 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.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 50b5401f2151d75c16a3f989fb0d5f87530a5113b9251c31b2f077f41bbfd2f0
MD5 a007d0a3f9d77b10bc1c52174f752fda
BLAKE2b-256 f2e22cdff99cffcc2ce762e9ead66d21945374c238d13dd8a7e9c52a55653c13

See more details on using hashes here.

Provenance

The following attestation bundles were made for injex-1.3.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