Dependency Injection¶
Warning
The current page still doesn't have a translation for this language.
But you can help translating it: Contributing.
Welcome to the definitive guide for using dependency injection in Lilya. In this document, we’ll explore
how to leverage the Provide
and Provides
primitives to cleanly manage shared resources, services, and configuration across your application,
includes (sub-applications), and individual routes.
Why Dependency Injection?¶
Dependency injection helps:
- Decouple business logic from infrastructure.
- Reuse services (e.g., database sessions, caches) without reinitializing them per request.
- Override behavior in testing or in specific sub-applications without changing core code.
- Compose complex services by injecting one into another (e.g., a token provider that needs a client).
Lilya’s lightweight DI makes these patterns straightforward, whether at the app level, include (module) level, or individual route level.
Core Primitives¶
Provide
¶
Use in your Lilya(...)
or Include(...)
constructor to register how to build a dependency. Signature:
import os
from lilya.apps import Lilya
from lilya.dependencies import Provide
# A simple config value
def load_config_value():
return os.getenv("PAYMENT_API_KEY")
app = Lilya(
dependencies={
"api_key": Provide(load_config_value)
}
)
Options:
use_cache=True/False
(defaultFalse
): cache the factory result for the lifetime of a request.
Provides
¶
Use in your handler signature to declare that a parameter should be injected:
from lilya.apps import Lilya
from lilya.dependencies import Provides
app = Lilya
@app.get("/charge")
async def charge_customer(api_key=Provides()):
# `api_key` is resolved via your Provide factory
return await make_charge(api_key)
Behind the scenes, Lilya collects all Provide
maps from app → include → route, then calls each factory in dependency order.
Application-Level Dependencies¶
Example: Database Session¶
Imagine you have an async ORM and want to share a session per request:
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, sessionmaker
from lilya.apps import Lilya
from lilya.dependencies import Provide, Provides
# Setup engine & factory
engine = create_async_engine(DATABASE_URL)
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with SessionLocal() as session:
yield session
app = Lilya(
dependencies={
"db": Provide(get_db, use_cache=True)
}
)
@app.get("/users/{user_id}")
async def read_user(user_id: int, db=Provides()):
user = await db.get(User, user_id)
return user.to_dict()
- We register
db
globally, cached per request. - Any route declaring
db = Provides()
receives the same session instance.
Include-Level Dependencies¶
Example: Feature Flag Service¶
Suppose you split your application into modules (includes) and each module needs its own feature-flag client:
from lilya.apps import Lilya
from lilya.routing import Include, Path
from lilya.dependencies import Provide, Provides
class FeatureFlagClient:
def __init__(self, env):
self.env = env
async def is_enabled(self, flag):
...
# App-wide
app = Lilya()
# Mount an admin module with its own flags
admin_flags = lambda: FeatureFlagClient(env="admin")
public_flags = lambda: FeatureFlagClient(env="public")
app.include(
path="/admin",
app=Include(
path="",
routes=[
Path(
"/dashboard",
handler=lambda flags=Provides(): flags.is_enabled("new_ui") and { ... }
)
],
dependencies={"flags": Provide(admin_flags)}
)
)
app.include(
path="/public",
app=Include(
path="",
routes=[
Path(
"/home",
handler=lambda flags=Provides(): flags.is_enabled("beta_banner")
)
],
dependencies={"flags": Provide(public_flags)}
)
)
Requests under /admin
get the admin client; under /public
get the public client—without manual passing.
Route-Level Overrides¶
You can override an include or app dependency for a specific route.
Example: A/B Test Handler¶
from lilya.apps import Lilya
from lilya.dependencies import Provide, Provides
app = Lilya(
dependencies={"experiment_group": Provide(lambda: "control")}
)
# Override for this route only
@app.get("/landing", dependencies={"experiment_group": Provide(lambda: "variant")})
async def landing(exp=Provides()):
if exp == "variant":
return {"ui": "new"}
return {"ui": "old"}
- Default group is
control
, but/landing
seesvariant
.
Nested Dependencies & Factories¶
Lilya resolves factories in topological order based on parameter names. Factories can themselves depend on other injections.
Example: OAuth Token Injection¶
from lilya.apps import Lilya
from lilya.dependencies import Provide, Provides
from httpx import AsyncClient
# 1) client factory
async def get_http_client():
return AsyncClient()
# 2) token factory uses client
async def get_access_token(client: AsyncClient=Provides()):
resp = await client.post("https://auth/", json={...})
return resp.json()["token"]
app = Lilya(
dependencies={
"client": Provide(get_http_client, use_cache=True),
"token": Provide(get_access_token)
}
)
@app.get("/secure-data")
async def secure_data(token=Provides()):
return await fetch_secure(token)
- Lilya sees
token
depends onclient
and injects accordingly.
Caching Behavior¶
By default, each factory runs once per request. If you pass use_cache=True
to Provide
, the result is reused in the same request context:
Provide(expensive_io, use_cache=True)
Ideal for DB sessions, HTTP clients, or feature-flag lookups.
Error Handling & Missing Dependencies¶
- Missing: if a handler requires
x=Provides()
but nox
factory is registered → 500 Internal Server Error. - Extra: if you register
x
but no handler parameter usesProvides()
→ImproperlyConfigured
at startup.
Always match Provide(...)
names with Provides()
parameters.
The Resolve dependency object¶
Lilya allows also to use what we call a "simpler" dependency injection. This dependency
injection system does not aim replace the Provide
or Provides
sytem but aims to provide another way of using some dependencies
in a simpler fashion in a non multi-layer fashion.
You can import directly from lilya.dependencies
:
from lilya.dependencies import Resolve
Warning
Resolve()
is not designed to work on an application level
as is. For application layers and dependencies, you must still use the normal dependency injection system to make it work
or use the Requires within the application layers..
A more detailed explanation¶
This is what Lilya describes as a simple dependency.
An example how to use Resolve
would be something like this:
from typing import Any
from typing import Any
from lilya.dependencies import Resolve
from lilya.routing import Path
from lilya.apps import Lilya
async def query_params(q: str | None = None, skip: int = 0, limit: int = 20):
return {"q": q, "skip": skip, "limit": limit}
async def get_params(params: dict[str, Any] = Resolve(query_params)) -> Any:
return params
app = Lilya(
routes=[Path("/items", handler=get_params)],
)
This example is very simple but you can extend to whatever you want and need. The Resolve
is not linked to any external library
but a pure Python class. You can apply to any other complex example and having a Resolve
inside more Resolve
s.
from typing import Any
from lilya.dependencies import Resolve
from lilya.routing import Path
from lilya.apps import Lilya
async def query_params(q: str | None = None, skip: int = 0, limit: int = 20):
return {"q": q, "skip": skip, "limit": limit}
async def get_user() -> dict[str, Any]:
return {"username": "admin"}
async def get_user(
user: dict[str, Any] = Resolve(get_user), params: dict[str, Any] = Resolve(query_params)
):
return {"user": user, "params": params}
async def get_info(info: dict[str, Any] = Resolve(get_user)) -> Any:
return info
app = Lilya(
routes=[Path("/info", handler=get_info)],
)
Resolve within the application layers¶
Now this is where things start to get interesting. Lilya operates in layers and almost everything works like that.
What if you want to use the Resolve
to operate on a layer level? Can you do it? Yes.
It works as we normally declare dependencies using the Provide
and Provides
.
from typing import Any
from typing import Any
from lilya.dependencies import Resolve, Provide, Provides
from lilya.routing import Path
from lilya.apps import Lilya
from lilya.responses import JSONResponse
async def get_user():
return {"id": 1, "name": "Alice"}
async def get_current_user(user: Any = Resolve(get_user)):
return user
async def get_items(current_user: Any = Provides()) -> JSONResponse:
return JSONResponse({"message": "Hello", "user": current_user})
app = Lilya(
routes=[
Path(
"/items", handler=get_items,
dependencies={
"current_user": Provide(get_current_user)
},
),
]
)
Best Practices¶
- Keep factories pure: avoid side effects outside creating the dependency.
- Cache long-lived resources (DB sessions, HTTP clients).
- Group dependencies by include for modular design.
- Override sparingly at route level—for true variations only.
- Document which dependencies each handler needs with clear parameter names.
With these patterns, you’ll keep your Lilya code clean, testable, and maintainable.