Ir para o conteúdo

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 (default False): 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 sees variant.

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 on client 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 no x factory is registered → 500 Internal Server Error.
  • Extra: if you register x but no handler parameter uses Provides()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 Resolves.

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.