Tiny typed dependency injection for Python apps
Project description
Injex
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
withblock. - 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
- Docs site
- Docs index
- Tutorial
- Validation guide
- Why Injex
- Comparison guide
- Performance notes
- Recipes
- Usage scenarios
- API reference
- Article: When Python manual wiring turns into copy-paste architecture
- Clean architecture example
- CLI application example
- FastAPI application service example
- Testing overrides example
- Scoped lifetime example
- Factories example
- Named registrations example
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:
- Muhammad Saqib Atif — FastAPI example.
- mahek —
resolve_all()documentation recipe. - oppnc — nested override regression tests.
- YuuGR1337 — README article link.
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6a0fcad130aab7c64f80c6d833c6e4fa08dee65d8d204d4b12206c3cdc7320cd
|
|
| MD5 |
27d3af1e46553fafe80d4a5bd30cc876
|
|
| BLAKE2b-256 |
56a0702241567943fccb1bdfd8c9c8ce98e5eef8c8db738a1669735aa75ff92d
|
Provenance
The following attestation bundles were made for injex-1.3.0.tar.gz:
Publisher:
release.yml on vshulcz/injex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
injex-1.3.0.tar.gz -
Subject digest:
6a0fcad130aab7c64f80c6d833c6e4fa08dee65d8d204d4b12206c3cdc7320cd - Sigstore transparency entry: 1738818319
- Sigstore integration time:
-
Permalink:
vshulcz/injex@c10e92f96de6233aea9c38cca16b22b40c36346c -
Branch / Tag:
refs/heads/main - Owner: https://github.com/vshulcz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c10e92f96de6233aea9c38cca16b22b40c36346c -
Trigger Event:
workflow_dispatch
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50b5401f2151d75c16a3f989fb0d5f87530a5113b9251c31b2f077f41bbfd2f0
|
|
| MD5 |
a007d0a3f9d77b10bc1c52174f752fda
|
|
| BLAKE2b-256 |
f2e22cdff99cffcc2ce762e9ead66d21945374c238d13dd8a7e9c52a55653c13
|
Provenance
The following attestation bundles were made for injex-1.3.0-py3-none-any.whl:
Publisher:
release.yml on vshulcz/injex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
injex-1.3.0-py3-none-any.whl -
Subject digest:
50b5401f2151d75c16a3f989fb0d5f87530a5113b9251c31b2f077f41bbfd2f0 - Sigstore transparency entry: 1738818325
- Sigstore integration time:
-
Permalink:
vshulcz/injex@c10e92f96de6233aea9c38cca16b22b40c36346c -
Branch / Tag:
refs/heads/main - Owner: https://github.com/vshulcz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c10e92f96de6233aea9c38cca16b22b40c36346c -
Trigger Event:
workflow_dispatch
-
Statement type: