Explicit Input, Declarative Output

Traditional web handlers receive a generic request object. Dependencies are implicit - scattered across globals, middleware, try/catch blocks.

Stario inverts this: your function signature declares exactly what it needs. Everything is explicit. Lifetimes are visible.

@app.command("/orders")
async def create_order(
    user: User,              # Request-scoped
    items: List[Product],    # Request-scoped
    db: Database,            # Singleton
):
    order = Order.create(user, items)
    yield render_order_confirmation(order)

No parsing. No session extraction. No cleanup code. Just domain logic.

How DI Works

Dependency injection gathers dependencies in parallel, then calls your function:

# What happens under the hood (pseudocode)
async def resolve_and_call(request):
    user, items, db = await asyncio.gather(
        get_authenticated_user(request),
        get_items_from_request(request),
        get_database_singleton(),
    )
    return await create_order(user, items, db)

You declare types. The framework builds the resolution graph automatically.

Dependencies Compose

Authentication is just a dependency. You can build on top of it:

# Define the resolver
async def get_authenticated_user(request: Request, db: Database) -> User:
    user_id = request.session.get("user_id")
    if not user_id:
        raise HTTPException(401)
    return await db.get_user(user_id)

AuthUser = Annotated[User, Depends(get_authenticated_user)]

# Build on top
async def get_admin_user(user: AuthUser) -> AdminUser:
    if user.role != "admin":
        raise HTTPException(403)
    return AdminUser(user)

AdminUser = Annotated[User, Depends(get_admin_user)]

# Use in endpoints
@app.command("/settings")
async def update_settings(admin: AdminUser):
    admin.apply_settings(config)

The framework builds the chain: RequestAuthUserAdminUser. If auth fails or the user isn't admin, the request never reaches your code. Authorization is part of your type system.

Why This Matters

Compare without DI:

async def create_order_manual(request: Request):
    global db_singleton
    user_id = request.session.get("user_id")
    if not user_id:
        raise HTTPException(401)
    user = await db_singleton.get_user(user_id)

    try:
        data = await request.json()
        items = await db_singleton.get_products(data['item_ids'])
    except Exception:
        raise HTTPException(422)

    order = Order.create(user, items)
    return JSONResponse({"order_id": order.id})

15 lines of extraction. 2 lines of logic. Globals hidden. Easy to leak connections. Hard to test.

Compare with DI:

async def create_order(user: User, items: List[Product], db: Database):
    order = Order.create(user, items)
    return {"order_id": order.id}

Read the signature, you know everything. No globals. No cleanup. No surprises. Testing is trivial - pass test objects.

The Mental Shift

Manual approach DI approach
Signature Says nothing:
handler(request: Request)
Is the contract:
handler(user: User, db: Database, settings: Settings)
What does it need? Read 50 lines Read the signature
Is it threadsafe? Hope so Yes, by design
How to test? Mock the world Pass test objects / mock dependencies

Everything your function needs is explicit. Everything it doesn't need is the framework's responsibility.

No implicit globals. No connection leaks. No "what if this is None?" questions. No concurrency bugs.

Write pure domain logic. Let the framework gather dependencies. Treat dependencies as extension of type system.