Tutorial
June 19, 2026 · View on GitHub
This walks through Injex feature by feature, starting from the smallest useful container and adding things as you hit a reason for them. If you just want the two-minute version, the README quick start is enough; come back here when you need factories, scopes, named registrations, or property injection.
For depth on a specific topic, each section links to its own guide.
The core loop
You register classes against the container at startup, then resolve them. Injex reads constructor type hints to figure out what to build.
from injex import Container
class UserRepository:
def save(self, email: str) -> int:
return 42
class RegisterUser:
def __init__(self, repo: UserRepository):
self.repo = repo
container = Container()
container.add_transient(UserRepository)
container.add_transient(RegisterUser)
use_case = container.resolve(RegisterUser) # UserRepository built and injected
RegisterUser never names UserRepository anywhere except its own constructor.
That is the whole point: classes declare what they need, the container decides
where instances come from.
Lifetimes
A registration has a lifetime that controls how often the instance is rebuilt:
add_singleton— built once, shared for the life of the container. Use it for configuration, connection pools, and clients.add_transient— a fresh instance on every resolve. Use it for use cases and anything that should not carry state between calls.add_scoped— one instance per scope (see Scopes below). Use it for request- or job-owned objects like a database session.
container.add_singleton(Settings)
container.add_transient(RegisterUser)
container.add_scoped(DbSession)
The second argument is the implementation, if it differs from the key:
container.add_singleton(Cache, RedisCache) # resolve(Cache) -> RedisCache
Factories
When construction needs logic the constructor can't express — reading an env var, building from a connection string — register a factory instead of a class. The factory's own parameters are injected too.
def make_pool(settings: Settings) -> ConnectionPool:
return ConnectionPool(settings.database_url, size=5)
container.add_singleton_factory(ConnectionPool, make_pool)
There is a factory variant for each lifetime: add_singleton_factory,
add_transient_factory, add_scoped_factory. Async (async def) factories and
async-generator resources go through the async API — see Async.
Resources with teardown
A generator factory is a resource: it yields the value and runs the code
after the yield as teardown. Scoped/transient resources are finalized when their
scope exits; singleton resources when container.close() is called (the container
is also a context manager).
def db_session(settings: Settings):
session = connect(settings.database_url)
try:
yield session
finally:
session.close()
container.add_scoped_factory(Session, db_session)
with container.create_scope() as scope:
session = scope.resolve(Session) # closed when the block exits
Auto-registration
For larger apps, marking classes and registering them in one call beats a long
list of add_* lines. Decorate with @injectable and scan a module:
from injex import Container, injectable
@injectable # transient by default
class UserRepository: ...
@injectable(lifestyle="singleton")
class ApiClient: ...
@injectable(provides=Notifier, name="email")
class EmailNotifier(Notifier): ...
import myapp.services
container = Container()
container.scan(myapp.services) # registers the marked classes above
scan registers only classes defined in the module (not imported ones), and
nothing happens until you call it — @injectable is just a marker, so importing
a module never registers anything behind your back. You can also pass an explicit
list: container.scan([UserRepository, ApiClient]).
Existing instances
If you already hold an object, register it directly. It is treated as a singleton.
container.add_instance(Settings, load_settings())
Calling functions
resolve builds objects; call invokes a function, filling its annotated
parameters from the container. Pass the rest yourself — a request, parsed CLI
args, a queue message. This is how you keep handlers free of wiring without
turning them into classes.
def register_user(email: str, use_case: RegisterUser) -> int:
return use_case.execute(email)
container.call(register_user, email="ada@example.com") # use_case is injected
acall is the async counterpart: it awaits async dependencies, awaits the
function if it's a coroutine, and finalizes any async resources opened for the
call when it returns.
For CLI commands, injex.ext.cli wraps this up: mark the service
parameters with Inject() and decorate the command with wire(container), and
Typer/Click only see the real CLI arguments.
from injex.ext.cli import Inject, wire
@app.command()
@wire(container)
def greet(name: str, greeter: Greeter = Inject()):
print(greeter.greet(name))
Scopes
A scope is a boundary that scoped services live inside of — typically one web request or one background job. Scoped services are shared within a scope and rebuilt for the next one.
container.add_scoped(DbSession)
with container.create_scope() as scope:
a = scope.resolve(UnitOfWork)
b = scope.resolve(UnitOfWork)
assert a.session is b.session # same DbSession within the scope
For the FastAPI wiring (container at startup, one scope per request) see
Recipes and examples/fastapi_app.py.
For async resources that must be opened and closed around a request, see
Async.
Resolving every implementation
Register the same key more than once and resolve_all returns all of them, in
registration order. This is how you build plugin lists, event handlers, or
middleware pipelines.
container.add_transient(Notifier, EmailNotifier)
container.add_transient(Notifier, SmsNotifier)
for notifier in container.resolve_all(Notifier):
notifier.send("hi")
More patterns: Resolving multiple implementations.
Named registrations
When you need two implementations of the same type side by side, give them names and resolve by name.
container.add_singleton(Database, PrimaryDatabase, name="primary")
container.add_singleton(Database, ReplicaDatabase, name="replica")
replica = container.resolve(Database, name="replica")
To inject a specific one into a constructor, mark the parameter with
Annotated[T, Named(...)]:
from typing import Annotated
from injex import Named
class ReportJob:
def __init__(self, db: Annotated[Database, Named("replica")]):
self.db = db # gets ReplicaDatabase
Optional dependencies
A parameter typed X | None (or with a default) resolves to None (or the
default) when nothing is registered for it, instead of raising.
class DataService:
def __init__(self, cache: Cache | None = None):
self.cache = cache
container.add_transient(DataService)
container.resolve(DataService).cache # None until a Cache is registered
Property injection
Constructor injection covers most cases. When you can't change a constructor —
a framework base class, for example — declare the dependency as an @inject
method and read it as an attribute.
from injex import inject
class Application:
@inject
def logger(self) -> Logger:
...
def run(self) -> None:
self.logger.info("starting")
Property injection falls off the compiled fast path, so prefer constructors when you have the choice.
Validation
assert_valid() checks the whole graph — missing registrations, missing
annotations, cycles — without constructing anything. Run it at startup or in a
test so wiring mistakes fail immediately instead of on the first request.
container.assert_valid()
Use validate() if you want the list of problems to format yourself. Details and
the exact rules are in Container validation.
Overrides in tests
override() swaps a registration inside a with block and restores it on exit,
so a test can inject a fake without touching the production container.
fake = FakePaymentGateway()
with container.override(PaymentGateway, instance=fake):
container.resolve(Checkout).pay(1999)
assert fake.charges == [1999]
Existing scoped instances are not rewritten, so open scopes inside the override block when a test exercises scoped services.
Cycles
If two services depend on each other, resolving raises
CyclicDependencyException rather than recursing forever:
class A:
def __init__(self, b: "B"): ...
class B:
def __init__(self, a: "A"): ...
container.add_transient(A)
container.add_transient(B)
container.resolve(A) # CyclicDependencyException
assert_valid() reports the same cycle before you ever resolve.
A larger example: a mediator
This ties the pieces together — resolve_all for the behavior pipeline, an
injected container, and a singleton composing transient behaviors around a
handler. A mediator decouples sending a request from handling it; behaviors add
cross-cutting concerns (logging, auth) without the handler knowing.
from abc import ABC, abstractmethod
from typing import Callable
from injex import Container
class Request:
def __init__(self, data: str):
self.data = data
class Handler(ABC):
@abstractmethod
def handle(self, request: Request) -> str: ...
class Behavior(ABC):
@abstractmethod
def process(self, request: Request, call_next: Callable[[], str]) -> str: ...
class LoggingBehavior(Behavior):
def process(self, request: Request, call_next: Callable[[], str]) -> str:
print(f"log: {request.data}")
return call_next()
class EchoHandler(Handler):
def handle(self, request: Request) -> str:
return f"processed: {request.data}"
class Mediator:
def __init__(self, container): # an unannotated `container` parameter is the container itself
self.container = container
def send(self, request: Request) -> str:
handler = self.container.resolve(Handler)
call = lambda: handler.handle(request)
# Wrap each behavior around the handler; first registered runs outermost.
for behavior in reversed(self.container.resolve_all(Behavior)):
call = lambda b=behavior, nxt=call: b.process(request, nxt)
return call()
container = Container()
container.add_transient(Behavior, LoggingBehavior)
container.add_transient(Handler, EchoHandler)
container.add_singleton(Mediator)
print(container.resolve(Mediator).send(Request("task")))
Errors
Every Injex exception subclasses DIException, so you can catch the specific one
or all of them. The full list and when each is raised is in the
API reference.