Dependency injection#
A small, zero-dependency DI layer: @Service/@Singleton register classes,
@Inject autowires functions, and a Container resolves the graph lazily.
The problem it solves#
As an application grows, wiring objects together by hand becomes its own
chore: a handler needs a service, which needs a repository, which needs a
database connection — and every call site has to build that whole chain in
the right order. In Java, Lombok’s @RequiredArgsConstructor provides the
constructor and Spring’s container does the wiring. inito gives you the same
split: the constructor decorators declare what
a class depends on (its typed constructor parameters), and a Container
resolves and builds the graph for you, once, on demand.
Crucially, @Service never rewrites your class — it stays an ordinary,
directly-constructible Python class. The container is an opt-in way to
build it, not a replacement for MyService(...).
Quick example#
from inito import Service, Singleton, Inject, RequiredArgsConstructor, default_container
@Singleton # one shared instance per container
class Database:
def __init__(self) -> None:
self.rows = {1: "Ada"}
@Service # autowired from the container on demand
@RequiredArgsConstructor
class UserRepository:
db: Database # a field annotation is all it takes
def name(self, user_id: int) -> str:
return self.db.rows[user_id]
@Inject # fills `repo` from the container
def handler(repo: UserRepository) -> str:
return repo.name(1)
print(handler()) # Ada
print(default_container.get(UserRepository)) # explicit resolution, same wiring
plain = UserRepository(Database()) # still an ordinary class
The pieces#
Symbol |
Role |
|---|---|
|
register a class in a container (defaults to singleton scope) |
|
sugar for |
|
wrap a function so its annotated parameters are filled from a container |
|
the registry + resolver; a shared |
|
|
@Component is a literal alias for @Service; use whichever name you
prefer.
How resolution works#
@Service reads a class’s constructor parameter types once, at
decoration time, and records them. Nothing is instantiated yet. The first
time you ask for the class — via container.get(cls) or an
@Inject-decorated call — the container resolves the dependency graph
bottom-up, builds each instance, and (for singletons) caches it.
A constructor parameter is autowired only if its annotated type is itself a registered service. A parameter whose type is not registered:
keeps its default value if it has one (plain config like
retries: int = 3just works), orraises
UnresolvableDependencyErrorif it has no default (the container has nothing to supply and no fallback).
@Service
class Cache:
def __init__(self, db: Database, ttl: int = 60) -> None:
... # db is autowired; ttl keeps its default
Scopes#
from inito import Service, Scope
Scope.SINGLETON(the default, and what@Singletonselects) builds the instance once and caches it — everyget()after the first returns the same object.Scope.TRANSIENTnever caches — everyget()rebuilds the subtree:@Service(scope=Scope.TRANSIENT) class RequestContext: def __init__(self) -> None: self.token = object()
One subtlety: a transient service used as a dependency of a singleton is built once — at the singleton’s first resolution — because the singleton that holds it is itself cached. “Transient” means “fresh each time it is resolved directly”, not “fresh inside every consumer”.
@Singleton is a standalone decorator, not something you stack on
@Service. Passing it a conflicting scope= argument raises
DecoratorConfigurationError — use @Service(scope=...) when you want a
non-default scope.
Containers#
@Service/@Singleton register into the shared default_container unless
you pass container=. Create your own Container to isolate a subsystem’s
registrations — especially handy in tests:
from inito import Service
from inito.di import Container
container = Container()
@Service(container=container)
class Repo:
...
container.get(Repo) # resolved from the isolated container
container.is_registered(Repo) # True
container.reset() # clear cached singletons (keeps registrations)
container.get(cls) is typed generically (type[T] -> T), so both mypy and
pyright infer the returned type with no plugin or stub needed.
Errors#
Exception |
When |
|---|---|
|
a constructor parameter has no type annotation, or a class is registered twice |
|
a needed type is unregistered and has no default; or |
|
the dependency graph has a cycle ( |
Performance and safety#
Warm path is cheap. Once a singleton is cached,
container.get(cls)is a single dict lookup; using the resolved object afterward is ordinary Python with no DI overhead. See Performance.@Injecthas a real per-call cost — it is the one decorator that does work on every call, since it fills a function’s parameters from the container each time it is invoked (signature inspection is still done once, at decoration time). It targets composition-root entry points (amain()or request handler), not hot-path methods.Thread-safe. Concurrent first-access to a singleton constructs it exactly once (double-checked locking, per registered class); dependencies are resolved before the lock is taken, so cyclic graphs raise cleanly instead of deadlocking.
Process-local. A
Containerlives in one process — like any in-memory Python object, its singletons are not shared acrossmultiprocessingworkers. Share cross-process state through a database, file, or shared memory instead.
See also#
Constructors —
@RequiredArgsConstructorpairs naturally with@Service.Recipes — a full service-layer example.
Troubleshooting — DI error walkthroughs.