Dependency Injection¶
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.
Without Provide
¶
Lilya also allows you to pass without Provide
and internally it will wrap it in a Provide
object
for you.
This of course comes with its limitations. The flags such as use_cache
cannot be injected as if you declare
Provide
directly.
Is this useful? It it on a case by case basis but it shows once again how flexible Lilya can be.
import os
from lilya.apps import Lilya
# A simple config value
def load_config_value():
return os.getenv("PAYMENT_API_KEY")
app = Lilya(
dependencies={
"api_key": load_config_value
}
)
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.
Tip
As from version 0.16.5, Lilya is able to discover the dependencies in a more automated manner without the need or use
of the Provides
but using the Provides
will always be available for any possible edge case you might find.
Dependency Injection Behavior¶
Lilya supports flexible dependency injection for both HTTP and WebSocket handlers. Dependencies can be declared explicitly using Provides()
or injected implicitly via fallback if defined in the app or route configuration.
Injection Scenarios¶
The following table summarizes how dependencies are handled based on how they are declared in the handler and whether they are available in the app's dependencies
mapping.
HTTP & WebSocket Handlers¶
Here is an example using session
(can be a database session) as a dependency.
Declaration Style | Declared in handler | Declared in dependencies |
Injected? | Error if missing? | Notes |
---|---|---|---|---|---|
session=Provides() |
✅ Yes | ✅ Yes | ✅ Yes | ❌ No | Explicit opt-in |
session=Provides() |
✅ Yes | ❌ No | ❌ No | ✅ Yes | Required but missing |
session: Any |
❌ No | ✅ Yes | ✅ Yes | ❌ No | Fallback injection |
session: Any |
❌ No | ❌ No | ❌ No | ❌ No | Skipped silently |
session: Any = None |
❌ No | ✅ Yes | ✅ Yes | ❌ No | Fallback + default value |
session (no type, no default) |
❌ No | ✅ Yes | ✅ Yes | ❌ No | Fallback injection |
Key Rules¶
- Explicit injection using
Provides()
makes the dependency required. If it's missing, an error is raised. - Fallback injection occurs when a parameter is:
- Present in
dependencies
, and - Declared in the handler without a default (
inspect.Parameter.empty
).
- Present in
- If a fallback dependency is not found, it is silently skipped.
- You can still provide default values (e.g.
= None
) to make parameters optional even if not injected.
Example¶
# App config
app = Lilya(dependencies={"session": Provide(get_session)})
# Handler with explicit injection
async def handler(session=Provides()):
...
# Handler with fallback injection
async def handler(session: Session):
...
# Handler with fallback + default
async def handler(session: Session = None):
...
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)
},
),
]
)
Depends and inject
¶
What if you simply want to use a dependency injection that simply does not rely on a request
/response
lifecycle?
For example, you have in your code some something that simply needs to run without it, example? A scheduler task?
Of course you do have more examples other than this one.
Well, Lilya now comes equipped with this possibility with a new Depends
and @inject
decorator.
from lilya.dependencies import Depends, inject
Depends
¶
Although the name might sounds familiar to a few, if not a lot of people, in Lilya world Depends
is similar to Resolve
but operates on an requestless world.
For the Depends
to work you will also need to have the @inject
decorator to help you out.
Let us go into an example. Let us assume you have some sort of scheduler running and you would need a database session to connect and perform some sort of operations.
Now, as you can see, you don't need a request/response for this, do you? (Celery tasks, for example?)
So you would have something like this:
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine
engine = create_engine(
"postgresql+psycopg2://user:password@localhost/dbname",
pool_recycle=600,
pool_pre_ping=True,
echo=False,
connect_args={
"keepalives": 1,
"keepalives_idle": 30,
"keepalives_interval": 5,
"keepalives_count": 3,
},
)
Sessionlocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""
Get the database session for the current request.
"""
session = Sessionlocal()
try:
yield session
finally:
session.close()
So far so good, right?
You can use the get_db
inside your Provide or Resolve as this can be
injected into your handlers but you cannot use it just like this if you need inside a task, can you?
Ok, this is where the Depends
and @inject
come to play.
Let us update the example to add an extra function that can then be used outside the request/response cycle.
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine
from lilya.dependencies import Depends, inject
engine = create_engine(
"postgresql+psycopg2://user:password@localhost/dbname",
pool_recycle=600,
pool_pre_ping=True,
echo=False,
connect_args={
"keepalives": 1,
"keepalives_idle": 30,
"keepalives_interval": 5,
"keepalives_count": 3,
},
)
Sessionlocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""
Get the database session for the current request.
"""
session = Sessionlocal()
try:
yield session
finally:
session.close()
@inject
def get_database_session(db = Depends(get_db)):
"""
Get the database session for non-request contexts.
"""
return db
Did you notice the new get_database_session
function? Well, this is how it works, you can now use
that function everywhere in your code and it will perform the dependency injection as you are used.
This is great as you can have normal Python functions that simply do not care about request lifecycle but you still want to use some sort of dependency injection for a myriad of reasons.
This new Depends
and @inject
will allow you to do just that and it will take care of generators,
scopes and so on as usual.
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.
Depends
andinject
are used outside the request/response cycle and must not be mistaken for the rest.
With these patterns, you’ll keep your Lilya code clean, testable, and maintainable.