Skip to content

Feature: Dependency Injection with Depends() for Event Handler #8099

@leandrodamascena

Description

@leandrodamascena

Use case

I've been thinking about how to improve the Event Handler developer experience and make it more Pythonic. Today the only way to inject shared logic or resources into route handlers is append_context(), which is essentially an untyped dict[str, Any]. It works, but it has real problems:

  • No type safety or IDE autocomplete -app.context["tenant_id"] can KeyError at runtime
  • Auth checks, tenant extraction, and resource setup end up duplicated across handlers or pushed into lambda_handler as manual setup
  • Testing requires building the context dict by hand or monkeypatching globals
  • No composition - you can't express that "orders_table depends on dynamodb_client"

FastAPI solved this years ago with Depends(). The pattern is well understood, widely adopted, and maps naturally to Lambda's execution model. This would be the single most impactful improvement to the Event Handler developer experience.

append_context() would remain for backward compatibility, but Depends() would make it unnecessary for most use cases.

Solution/User Experience

from aws_lambda_powertools.event_handler import APIGatewayHttpResolver
from aws_lambda_powertools.event_handler.openapi.params import Depends

app = APIGatewayHttpResolver()

# Dependency: extract tenant from authorizer
def get_tenant(request: Request) -> str:
    ctx = request.current_event.request_context.authorizer.get_context()
    tenant_id = ctx.get("tenant_id")
    if not tenant_id:
        raise UnauthorizedError("Missing tenant")
    return tenant_id

# Dependency: DynamoDB table (cacheable across invocations)
def get_orders_table() -> Table:
    return boto3.resource("dynamodb").Table(os.environ["ORDERS_TABLE"])

# Handler: clean, typed, testable
@app.get("/orders")
def list_orders(
    tenant_id: str = Depends(get_tenant),
    table: Table = Depends(get_orders_table),
):
    return table.query(KeyConditionExpression=Key("pk").eq(tenant_id))["Items"]

Dependencies can depend on other dependencies, forming a composable tree:

def get_dynamodb() -> DynamoDBClient:
    return boto3.resource("dynamodb")

def get_users_table(db: DynamoDBClient = Depends(get_dynamodb)) -> Table:
    return db.Table(os.environ["USERS_TABLE"])

@app.get("/users/me")
def get_profile(
    tenant_id: str = Depends(get_tenant),
    table: Table = Depends(get_users_table),
):
    return table.get_item(Key={"pk": tenant_id})["Item"]

Testing becomes trivial with dependency overrides - no mocks, no monkeypatching:

def test_list_orders():
    app.dependency_overrides[get_tenant] = lambda: "test-tenant"
    app.dependency_overrides[get_orders_table] = lambda: mock_table
    result = app.resolve(apigw_event, context)
    app.dependency_overrides.clear()

How it compares to append_context

append_context Depends()
Type safety dict[str, Any] Return type of the function
IDE autocomplete No Yes
Missing dependency KeyError at runtime Clear resolution error
Where logic lives Outside, in lambda_handler In the dependency function
Testability Build dict manually dependency_overrides
Composition Flat Recursive tree
Reusability Copy-paste append_context calls Import the function

Implementation plan

Split into 3 PRs, each independently mergeable.

1. Core Depends class and dependency resolution

  1. Add Depends class to openapi/params.py with dependency: Callable and use_cache: bool
  2. Extend get_dependant() in openapi/dependant.py to detect Depends in function signatures and recursively build sub-dependant trees
  3. Add _solve_dependencies() to resolve the tree bottom-up with per-invocation cache
  4. Wire into the route resolution flow in api_gateway.py - after parameter extraction, before handler call
  5. Dependencies that type-hint Request receive the current request object automatically
  6. No changes to existing parameter handling - Depends is purely additive

2. Dependency overrides for testing

  1. Add dependency_overrides: dict[Callable, Callable] to ApiGatewayResolver
  2. During resolution, check overrides before calling the original dependency
  3. Router composition: child routers inherit parent's overrides

3. OpenAPI integration

  1. When a dependency's parameter has a security type (OAuth2, APIKey, etc.), propagate to the route's OpenAPI security requirements
  2. Security scheme dependencies auto-register in the OpenAPI components
  3. This makes security enforcement and documentation a single declaration

What we would NOT do (vs FastAPI)

  • Generator/yield dependencies - In FastAPI these handle setup/teardown (e.g. DB sessions). Lambda invocations are short-lived, so this adds complexity with little value
  • Async dependencies - Powertools is sync. No need

Caching strategy

Dependencies are cached per invocation (resolve() call) by default. If the same dependency is used by multiple handlers or sub-dependencies in the same invocation, it gets resolved once and the result is reused. The cache is cleared at the end of resolve().

  • use_cache=True (default): cache within the same invocation
  • use_cache=False: resolve every time it's called

For expensive resources that should persist across invocations (boto3 clients, table references), customers can continue using module-level variables as they do today - that pattern works well and doesn't need to be replaced.

Alternative solutions

The current `append_context()` approach works but is untyped and requires manual setup in `lambda_handler`. The alternative would be to improve `append_context` with type hints (e.g. a TypedDict), but that still wouldn't give us composition, automatic resolution, or dependency overrides for testing, etc.

Acknowledgment

Metadata

Metadata

Labels

Type

No type

Projects

Status

Working on it

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions