-
Notifications
You must be signed in to change notification settings - Fork 473
Feature: Dependency Injection with Depends() for Event Handler #8099
Description
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"]canKeyErrorat runtime - Auth checks, tenant extraction, and resource setup end up duplicated across handlers or pushed into
lambda_handleras 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
- Add
Dependsclass toopenapi/params.pywithdependency: Callableanduse_cache: bool - Extend
get_dependant()inopenapi/dependant.pyto detectDependsin function signatures and recursively build sub-dependant trees - Add
_solve_dependencies()to resolve the tree bottom-up with per-invocation cache - Wire into the route resolution flow in
api_gateway.py- after parameter extraction, before handler call - Dependencies that type-hint
Requestreceive the current request object automatically - No changes to existing parameter handling -
Dependsis purely additive
2. Dependency overrides for testing
- Add
dependency_overrides: dict[Callable, Callable]toApiGatewayResolver - During resolution, check overrides before calling the original dependency
- Router composition: child routers inherit parent's overrides
3. OpenAPI integration
- When a dependency's parameter has a security type (OAuth2, APIKey, etc.), propagate to the route's OpenAPI security requirements
- Security scheme dependencies auto-register in the OpenAPI components
- 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 invocationuse_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
- This feature request meets Powertools for AWS Lambda (Python) Tenets
- Should this be considered in other Powertools for AWS Lambda languages? i.e. Java, TypeScript, and .NET
Metadata
Metadata
Assignees
Labels
Type
Projects
Status