Declaring an Injectable
Everything Lauren constructs through DI is an "injectable". This guide walks through the full lifecycle: declaring a class, choosing a scope, wiring lifecycle hooks, binding to a Protocol, and verifying the result with the test client.
The minimum viable injectable
from lauren import injectable
@injectable()
class Clock:
def now(self) -> float:
import time
return time.monotonic()@injectable() (note the parentheses — bare @injectable is rejected) attaches an InjectableMeta(scope=SINGLETON, provides=None, multi=False) payload to the class and returns the class unchanged. No wrapping, no monkey-patching.
To make Clock reachable from a controller, register it in a module's providers list:
@module(controllers=[MyController], providers=[Clock])
class AppModule: ...That's it. Any controller or other injectable in the same module — or any module that imports this one's exports — can take Clock as a constructor parameter.
Choosing a scope
from lauren import injectable, Scope
@injectable(scope=Scope.SINGLETON) # default — one per app
@injectable(scope=Scope.REQUEST) # one per request
@injectable(scope=Scope.TRANSIENT) # new every resolvePick by lifetime:
| Scope | Pick when... |
|---|---|
SINGLETON | The instance has no per-request state — caches, configs, clients with internal pools. |
REQUEST | The instance carries request-bound state — DB sessions, current-user objects, per-request caches. |
TRANSIENT | You need a fresh instance every time (rare in practice — usually a sign of a stateful builder). |
Lauren enforces scope rules at startup:
- A
SINGLETONmay not depend on anything narrower (request-scoped) — it would be a stale reference outside any request. - A
REQUESTinjectable can mixSINGLETONandREQUESTdeps freely. - A
TRANSIENTcan depend on anything.
A violation raises DIScopeViolationError at boot.
Constructor injection
Take dependencies in __init__. Lauren resolves them through the container:
@injectable()
class UserService:
def __init__(self, repo: UserRepository, clock: Clock) -> None:
self.repo = repo
self.clock = clockThe container looks up each parameter's type annotation as a token. Types must be visible from the same module (or imported through exports).
Optional parameters with defaults
If a parameter has a default value, the container treats it as optional: the default is used when no provider exists.
from dataclasses import dataclass
@injectable(scope=Scope.SINGLETON)
@dataclass
class Settings:
database_url: str = "sqlite:///•"
jwt_secret: str = "dev"This is what makes @dataclass-backed config objects work without any extra ceremony.
Non-class tokens with Inject
Some providers don't have a class to attach to — a database URL string, a third-party client built externally, an opaque ID. Use a Token and the Inject marker:
from typing import Annotated
from lauren import injectable, Inject, Token, use_value
DB_URL = Token("DB_URL")
# Module:
@module(providers=[
use_value(provide=DB_URL, value="postgres://localhost/app"),
])
class AppModule: ...
# Consumer:
@injectable()
class Repo:
def __init__(self, url: Annotated[str, Inject(DB_URL)]) -> None:
self.url = urlStatic checkers still see url: str. The runtime resolution uses DB_URL as the lookup key.
Class-field injection (alternative)
Lauren also supports class-field injection for parity with NestJS:
@injectable()
class Repo:
db: Database
clock: ClockFunctionally equivalent to constructor injection — pick whichever style your team prefers. Annotated fields work too:
@injectable()
class Repo:
url: Annotated[str, Inject(DB_URL)]Binding to Protocols
provides=[Protocol] registers the class as an implementation of one or more Protocol interfaces:
from typing import Protocol, runtime_checkable
from lauren import injectable
@runtime_checkable
class EmailSender(Protocol):
def send(self, to: str, msg: str) -> None: ...
@injectable(provides=[EmailSender])
class SmtpSender:
def send(self, to, msg):
...
@injectable()
class Notifier:
def __init__(self, sender: EmailSender) -> None: # resolves to SmtpSender
self._sender = senderIf two classes both provides=[EmailSender] without multi=True, startup fails with ProtocolAmbiguityError. The container forces an explicit choice.
Multi-bindings — list[T]
When you want all providers of a Protocol, declare them with multi=True and accept a list:
@injectable(provides=[EmailSender], multi=True)
class SmtpSender: ...
@injectable(provides=[EmailSender], multi=True)
class SmsSender: ...
@injectable()
class Dispatcher:
def __init__(self, senders: list[EmailSender]) -> None:
self._senders = senderslist[T] injection works in every position — constructor params, class fields, and handler parameters. Asking for list[T] when T isn't multi-registered raises ProtocolAmbiguityError.
Lifecycle hooks
from lauren import injectable, post_construct, pre_destruct
@injectable()
class Db:
@post_construct
async def connect(self) -> None:
self.pool = await asyncpg.create_pool(DSN)
@pre_destruct
async def disconnect(self) -> None:
await self.pool.close()@post_construct— runs after construction, in topological order (deps first).@pre_destruct— runs at shutdown, reverse topological order, with per-hook timeouts.aclose(self)(async) on a request-scoped injectable is awaited automatically after every request.
See Lifecycle Hooks for the full timing model.
Strict inheritance — opt-in only
If you subclass an @injectable for code reuse, the subclass is not automatically an injectable:
@injectable()
class Base: ...
class Internal(Base):
pass # registering this as a provider raises MetadataInheritanceError
@injectable()
class External(Base):
pass # OK — explicit opt-inThis applies to controllers, modules, middleware, and exception handlers too. See Class Inheritance Rules.
Function-based providers
@injectable() works on plain functions too. The function's return value becomes
the dependency, and its parameters are resolved through the DI container exactly like
a class constructor's:
from lauren import injectable, Scope
@injectable()
def db_url() -> str:
import os
return os.environ.get("DATABASE_URL", "sqlite:///•")
@injectable()
class UserRepo:
def __init__(self, url: str) -> None: # url resolved via db_url factory
self.url = urlKey rules:
-
The function is the token — consumers inject it with
Depends[factory_fn]:python@injectable() class Service: url: Depends[db_url] # field injection works too -
Async factories are awaited automatically:
python@injectable() async def make_pool(url: str) -> asyncpg.Pool: return await asyncpg.create_pool(url) -
Scopes work identically.
SINGLETONmeans the factory is called once:python@injectable(scope=Scope.SINGLETON) def make_pool(url: str) -> asyncpg.Pool: ... # called once per app -
Registering the same function twice raises
DuplicateBindingErrorat startup. -
A missing dependency in the function's params raises
MissingProviderErrorat compile time.
Register the function in providers=[] like any class:
@module(providers=[db_url, make_pool, UserRepo])
class AppModule: ...Verifying with the test client
from lauren.testing import TestClient
from lauren import LaurenFactory
# LaurenFactory.create() is synchronous — no asyncio.run() needed.
app = LaurenFactory.create(AppModule)
c = TestClient(app) # startup() runs here; @post_construct hooks fire
# You can also reach into the container directly for assertions:
import asyncio
clock = asyncio.run(app.container.resolve(Clock))
assert isinstance(clock, Clock)For tests that need to swap an injectable, install an explicit singleton:
class FakeClock:
def now(self) -> float: return 1234.0
app.container.set_singleton(Clock, FakeClock())Common pitfalls
| Symptom | Likely cause |
|---|---|
MissingProviderError: Clock | The provider isn't in this module's providers, or the module that exports it isn't in imports. |
DIScopeViolationError: ... SINGLETON depends on REQUEST | Move the singleton to REQUEST scope, or take the request-scoped dep per-call instead of per-instance. |
MetadataInheritanceError: ChildClass | A subclass isn't redecorated. Add @injectable() (or @controller(...), etc.) to the child. |
ProtocolAmbiguityError: EmailSender | Two providers both provides=[EmailSender] without multi=True. Decide one or mark both multi=True. |
UnresolvableParameterError: param 'foo' | Constructor param has no annotation and no default. Add either. |
See also
- Core Concepts → Injectables & Providers — the conceptual reference.
- Custom Providers —
use_value,use_class,use_factory,use_existingfor cases this guide doesn't cover. - Lifecycle Hooks — full timing model.