In the previous post we stood up a basic /ping health‑check endpoint to confirm our SAM project was wired correctly.
Today we will build our first real piece of CRM functionality: a POST /contacts
endpoint that stores a contact record in Amazon DynamoDB, now powered by AWS Lambda Powertools for structured logging, tracing, and metrics.
The API Contract
Request POST /contacts
{
"email": "jane.doe@example.com", // required
"name": "Jane Doe", // optional
"phone": "+11234567890", // optional
}
Response 201 Created
{
"id": "c8a5c3d1-2bfe-45d0-b7e3-6d1fb25ce9a7",
"email": "jane.doe@example.com",
"name": "Jane Doe",
"phone": "+11234567890",
"created_at": 1721340000,
"updated_at": 1721340000
}
We return the full record, including generated fields such as id
, created_at
, and updated_at
Designing the Data Model
For now we will use a DynamoDB table named ContactsTable
with:
Attribute | Purpose | Key type |
---|---|---|
pk |
contact ID (UUID) | PartitionKey |
sk |
static value METADATA
|
SortKey |
other attributes | email, name, etc | (projected) |
In DynamoDB, the partition key (pk
) decides which storage partition the item belongs to, while the sort key (sk
) lets us order or group multiple items that share that same partition. By using a fixed sk
value like METADATA
for the main contact record today, we reserve the option to add related entities—such as notes or tags—under the same pk
but with different sk
values later on.
Dependency management
We will create a python virtual environment per lambda function, so that we can manage their package versions in isolation.
Create and activate the venv
python -m venv venvs/contacts
source venvs/contacts/bin/activate
Install the dependencies and lock them:
pip install "aws-lambda-powertools[all]"
pip freeze > src/functions/contacts/requirements.txt
sam build
will detect requirements.txt
and bundle these exact versions into the deployment artifact.
Contacts Lambda function
The lambda function will recieve a payload with the contact data which will be validated with pydantic, then we store the contact data in DynamoDB where we're abstracting away all the data access logic by following the repository pattern, and finally we return the contact in the response, including system generated attributes such as id
, created_at
, and updated_at
.
File structure:
src/functions/contacts/
├─ app/
| ├─ __init__.py
| ├─ main.py # Lambda entry‑point
| ├─ models.py # Pydantic models
| └─ repository.py # Data‑access abstraction
├─ __init__.py
├─ requirements.txt # Locked package dependencies
└─ events/
└─ create_contact.json # API Gateway payload for testing
models.py
from pydantic import BaseModel, EmailStr
from typing import Optional
class CreateContactRequest(BaseModel):
email: EmailStr
name: Optional[str] = None
phone: Optional[str] = None
class Contact(BaseModel):
id: str
email: EmailStr
name: Optional[str] = None
phone: Optional[str] = None
created_at: str
updated_at: str
repository.py
import os, uuid
from datetime import datetime, timezone
import boto3
from .models import CreateContactRequest, Contact
class ContactRepository:
def __init__(self):
self.contacts_table_name: str = os.environ.get("CONTACTS_TABLE_NAME")
self.contacts_table = boto3.resource("dynamodb").Table(self.contacts_table_name)
def create(self, create_contact_request: CreateContactRequest) -> Contact:
now = datetime.now(timezone.utc).isoformat()
contact_id = str(uuid.uuid4())
item = {
"pk": contact_id,
"sk": "METADATA",
**create_contact_request.model_dump(),
"created_at": now,
"updated_at": now,
}
self.contacts_table.put_item(Item=item, ConditionExpression="attribute_not_exists(pk)")
return Contact(
id=contact_id,
**create_contact_request.model_dump(),
created_at=now,
updated_at=now
)
main.py
from aws_lambda_powertools import Logger, Metrics, Tracer
from aws_lambda_powertools.metrics import MetricUnit
from aws_lambda_powertools.event_handler import APIGatewayRestResolver, CORSConfig
from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.utilities.typing import LambdaContext
from typing import Dict, Any
from .models import CreateContactRequest, Contact
from .repository import ContactRepository
tracer = Tracer()
logger = Logger()
metrics = Metrics()
contact_repository = ContactRepository()
cors_config = CORSConfig(allow_origin="*", max_age=300)
app = APIGatewayRestResolver(cors=cors_config, enable_validation=True)
@app.post("/contacts")
@tracer.capture_method
def create_contact(create_contact_request: CreateContactRequest) -> Contact:
contact = contact_repository.create(create_contact_request)
metrics.add_metric(name="ContactsCreated", unit=MetricUnit.Count, value=1)
tracer.put_annotation("contact_id", contact.id)
return contact, 201
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST)
@tracer.capture_lambda_handler
@metrics.log_metrics(capture_cold_start_metric=True)
def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]:
return app.resolve(event, context)
@app.exception_handler(Exception)
@tracer.capture_method
def handle_exception(ex: Exception):
logger.exception("Internal server error", extra={"error": str(ex)})
return {"message": "Internal server error"}, 500
Build, test, deploy
In addition to testing the endpoint with curl, we’ll run the Lambda function locally. That requires passing it a mock event. Because the function is invoked by API Gateway in production, the mock event must replicate the JSON payload that API Gateway sends to the Lambda.
// src/functions/contacts/events/create_contact.json
{
"httpMethod": "POST",
"path": "/contacts",
"headers": {
"Content-Type": "application/json"
},
"requestContext": {
"httpMethod": "POST"
},
"body": "{\"email\": \"jane.doe@example.com\", \"name\": \"Jane Doe\", \"phone\": \"+11234567890\"}",
"isBase64Encoded": false
}
we also need a .env
file with the values of the lambda function's environment variables for when we run it locally
// .env
{
"ContactsFunction": {
"CONTACTS_TABLE_NAME": "crm-sam-contacts-table"
}
}
and now we can proceed to build
sam build
run the lambda function locally
sam local invoke ContactsFunction \
-e src/functions/contacts/events/create_contact.json
-n .env
{"statusCode": 201, "body": "{\"id\":\"788c7d86-30a6-46da-9550-27e42f7686cd\",\"email\":\"jane.doe@example.com\",\"name\":\"Jane Doe\",\"phone\":\"+11234567890\",\"created_at\":\"2025-07-20T14:59:28.811723+00:00\",\"updated_at\":\"2025-07-20T14:59:28.811723+00:00\"}", "isBase64Encoded": false, "multiValueHeaders": {"Content-Type": ["application/json"]}}
run the API locally
sam local start-api
curl -X POST http://127.0.0.1:3000/contacts \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","name":"Alice"}'
{"id":"ac1c51b7-2fe6-495f-8578-70c123c8927f","email":"alice@example.com","name":"Alice","phone":null,"created_at":"2025-07-20T15:11:21.174541+00:00","updated_at":"2025-07-20T15:11:21.174541+00:00"}
and deploy it
sam deploy
curl -X POST https://uywf3xcoej.execute-api.us-east-1.amazonaws.com/prod/contacts \
-H 'Content-Type: application/json' \
-d '{"email":"bob@example.com","name":"Bob"}'
{"id":"a7ea3b2c-6b7c-422a-b8eb-36795f39e537","email":"bob@example.com","name":"Bob","phone":null,"created_at":"2025-07-20T15:15:29.276161+00:00","updated_at":"2025-07-20T15:15:29.276161+00:00"}
What did we learn?
- Use of AWS Lambda Powertools to simplify the lambda function code
- Use of pydantic for data validation
- Use of the repository pattern to abstract data access logic
- Dependency management for lambda functions
And with that, we already have the first CRM feature. If you want to see the code of the project, you can find it here, and if you have and suggestion or feedback, let me know in the comments.
Coming next
Now that we can create contacts, in part 3 we will retrieve them (GET /contacts
), add pagination, and explore query patterns.
Top comments (1)
Looking forward to part 3.