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
- Add
Depends class to openapi/params.py with dependency: Callable and use_cache: bool
- Extend
get_dependant() in openapi/dependant.py to detect Depends in 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
Request receive the current request object automatically
- No changes to existing parameter handling -
Depends is purely additive
2. Dependency overrides for testing
- Add
dependency_overrides: dict[Callable, Callable] to ApiGatewayResolver
- 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 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
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 untypeddict[str, Any]. It works, but it has real problems:app.context["tenant_id"]canKeyErrorat runtimelambda_handleras manual setupFastAPI 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, butDepends()would make it unnecessary for most use cases.Solution/User Experience
Dependencies can depend on other dependencies, forming a composable tree:
Testing becomes trivial with dependency overrides - no mocks, no monkeypatching:
How it compares to
append_contextappend_contextDepends()dict[str, Any]KeyErrorat runtimelambda_handlerdependency_overridesappend_contextcallsImplementation plan
Split into 3 PRs, each independently mergeable.
1. Core
Dependsclass and dependency resolutionDependsclass toopenapi/params.pywithdependency: Callableanduse_cache: boolget_dependant()inopenapi/dependant.pyto detectDependsin function signatures and recursively build sub-dependant trees_solve_dependencies()to resolve the tree bottom-up with per-invocation cacheapi_gateway.py- after parameter extraction, before handler callRequestreceive the current request object automaticallyDependsis purely additive2. Dependency overrides for testing
dependency_overrides: dict[Callable, Callable]toApiGatewayResolver3. OpenAPI integration
What we would NOT do (vs FastAPI)
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 ofresolve().use_cache=True(default): cache within the same invocationuse_cache=False: resolve every time it's calledFor 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
Acknowledgment