After three years of writing AWS Lambda functions that sit behind Amazon Connect and Amazon Lex, I’ve settled on a set of patterns that keep the code clean, the tests fast, and production incidents rare. This post distills those patterns into something you can use immediately.
The examples are Python, but the thinking applies regardless of runtime.
The most common mistake I see is cramming all business logic into lambda_handler. The handler has one job — unpack the event, call your logic, and pack the response. Everything else belongs in separate functions.
# Bad — untestable monolith
def lambda_handler(event, context):
intent = event['sessionState']['intent']['name']
account_type = event['sessionState']['intent']['slots']['AccountType']['value']['interpretedValue']
# ... 80 more lines of logic ...
return { ... }
# Good — thin handler
def lambda_handler(event, context):
source = event['invocationSource']
intent = event['sessionState']['intent']
slots = intent.get('slots', {})
session = event['sessionState'].get('sessionAttributes', {})
if source == 'DialogCodeHook':
return handle_dialog(intent, slots, session)
return handle_fulfillment(intent, slots, session)
Now handle_dialog and handle_fulfillment are plain functions you can unit-test without any Lambda infrastructure.
Building Lex V2 response dicts by hand in every function leads to typos and forgotten fields. Centralise it:
# lex_response.py
def delegate(intent_name, slots, session_attrs):
return {
"sessionState": {
"sessionAttributes": session_attrs,
"dialogAction": {"type": "Delegate"},
"intent": {"name": intent_name, "slots": slots, "state": "InProgress"}
}
}
def elicit_slot(intent_name, slots, session_attrs, slot_to_elicit, message):
return {
"sessionState": {
"sessionAttributes": session_attrs,
"dialogAction": {"type": "ElicitSlot", "slotToElicit": slot_to_elicit},
"intent": {"name": intent_name, "slots": slots, "state": "InProgress"}
},
"messages": [{"contentType": "PlainText", "content": message}]
}
def close(intent_name, slots, session_attrs, message, state="Fulfilled"):
return {
"sessionState": {
"sessionAttributes": session_attrs,
"dialogAction": {"type": "Close"},
"intent": {"name": intent_name, "slots": slots, "state": state}
},
"messages": [{"contentType": "PlainText", "content": message}]
}
def confirm_intent(intent_name, slots, session_attrs, message):
return {
"sessionState": {
"sessionAttributes": session_attrs,
"dialogAction": {"type": "ConfirmIntent"},
"intent": {"name": intent_name, "slots": slots, "state": "InProgress"}
},
"messages": [{"contentType": "PlainText", "content": message}]
}
Your intent handlers become much more readable:
from lex_response import elicit_slot, delegate, close
def validate_transfer(intent, slots, session):
amount = get_slot_value(slots, 'TransferAmount')
if amount and float(amount) > 10_000:
return elicit_slot(
intent['name'], slots, session,
slot_to_elicit='TransferAmount',
message="Transfers are limited to $10,000. Please enter a lower amount."
)
return delegate(intent['name'], slots, session)
Lex V2 slot values are buried several levels deep and can be None at multiple levels. Accessing them safely without a helper produces deeply nested conditionals:
def get_slot_value(slots, slot_name):
"""Return the interpretedValue for a slot, or None if absent."""
slot = slots.get(slot_name)
if not slot:
return None
value = slot.get('value')
if not value:
return None
return value.get('interpretedValue')
def get_slot_values(slots, *names):
"""Return a dict of {name: interpretedValue} for multiple slots at once."""
return {name: get_slot_value(slots, name) for name in names}
Usage:
account_type, amount = (
get_slot_value(slots, 'AccountType'),
get_slot_value(slots, 'TransferAmount')
)
Connect’s hard limit for a Lambda invocation is 8 seconds. If your Lambda calls a downstream API, you must enforce a timeout shorter than that or a slow API will cause Connect to return an error branch.
import requests
from botocore.exceptions import ClientError
DOWNSTREAM_TIMEOUT = 5 # seconds — leave headroom for Lambda cold start + Lex overhead
def fetch_account_balance(account_id: str) -> dict:
try:
resp = requests.get(
f"https://internal-api.example.com/accounts/{account_id}/balance",
timeout=DOWNSTREAM_TIMEOUT,
headers={"x-api-key": get_secret("BALANCE_API_KEY")}
)
resp.raise_for_status()
return resp.json()
except requests.Timeout:
raise RuntimeError(f"Balance API timed out after {DOWNSTREAM_TIMEOUT}s")
except requests.HTTPError as e:
raise RuntimeError(f"Balance API returned {e.response.status_code}")
requests.Session and AWS SDK clients outside the handler at module level. Lambda reuses the execution environment between warm invocations, so connection pools are reused and you avoid per-invocation connection overhead.
CloudWatch Logs Insights is your debugger in production. JSON logs let you query them like a database.
import json, logging, os
logger = logging.getLogger()
logger.setLevel(os.environ.get('LOG_LEVEL', 'INFO'))
def log(level: str, message: str, **kwargs):
entry = {"level": level, "message": message, **kwargs}
getattr(logger, level.lower())(json.dumps(entry))
# Usage in your handler
log("info", "Dialog codehook invoked",
intent=intent['name'],
session_id=event.get('sessionId'),
slots={k: get_slot_value(slots, k) for k in slots})
In CloudWatch Logs Insights you can now filter by intent, session ID, or any field:
fields @timestamp, message, intent, session_id
| filter level = "error"
| sort @timestamp desc
| limit 50
Hardcoding secrets in env vars means they’re visible in the Lambda console and in CloudTrail. Use SSM Parameter Store or Secrets Manager instead, and cache the value in the module scope so you only pay the SSM API call on cold start:
import boto3, functools
ssm = boto3.client('ssm', region_name='us-east-1')
@functools.lru_cache(maxsize=None)
def get_secret(param_name: str) -> str:
response = ssm.get_parameter(Name=param_name, WithDecryption=True)
return response['Parameter']['Value']
ssm:GetParameter on the specific parameter ARN — not ssm:* on *.
As the number of intents grows, a chain of if/elif blocks in the handler becomes unmanageable. A router pattern scales cleanly:
from intents import check_balance, transfer_funds, report_lost_card
DIALOG_HANDLERS = {
"CheckBalance": check_balance.handle_dialog,
"TransferFunds": transfer_funds.handle_dialog,
"ReportLostCard": report_lost_card.handle_dialog,
}
FULFILL_HANDLERS = {
"CheckBalance": check_balance.fulfill,
"TransferFunds": transfer_funds.fulfill,
"ReportLostCard": report_lost_card.fulfill,
}
def lambda_handler(event, context):
source = event['invocationSource']
intent = event['sessionState']['intent']
name = intent['name']
slots = intent.get('slots', {})
session = event['sessionState'].get('sessionAttributes', {})
registry = DIALOG_HANDLERS if source == 'DialogCodeHook' else FULFILL_HANDLERS
handler = registry.get(name)
if not handler:
log("error", "Unknown intent", intent=name)
return close(name, slots, session, "I'm not sure how to help with that. Transferring you now.")
return handler(intent, slots, session)
Each intent lives in its own module (intents/check_balance.py, etc.) with handle_dialog and fulfill functions — easy to find, easy to test in isolation.
lambda/
├── handler.py # lambda_handler + router
├── lex_response.py # response builders
├── slot_utils.py # get_slot_value, get_slot_values
├── secrets.py # get_secret with lru_cache
├── logger.py # structured JSON logger
└── intents/
├── __init__.py
├── check_balance.py
├── transfer_funds.py
└── report_lost_card.py
tests/
├── test_check_balance.py
├── test_transfer_funds.py
└── fixtures/
└── lex_events.json # sample Lex V2 event payloads for unit tests
tests/fixtures/lex_events.json. Your unit tests load it and manipulate specific fields — no mocking of AWS services required, no internet connection needed, test suite runs in milliseconds.
lambda_handler thin — route to focused functions.lex_response.py.