DEV Community

Aisalkyn Aidarova
Aisalkyn Aidarova

Posted on

Project: Serverless Orders + Profiles

What you’ll build (final output)

  1. Public API (GET /health) to verify the stack.
  2. Protected API (/profiles/*, /orders/*) fronted by API Gateway + Cognito:
  • POST /profiles – create/update a user profile (DynamoDB)
  • GET /profiles/{id} – fetch profile (DynamoDB)
  • POST /orders – place an order (invokes Step Functions workflow)
  • GET /orders/{id} – fetch order status (DynamoDB)

    1. Background workflows:
  • Step Functions orchestrates “Place Order” (validate → reserve → charge → confirm).

  • EventBridge cron runs a serverless cleanup Lambda hourly to expire “stale orders”.

  • DynamoDB Streams → Lambda sends a “welcome” log when a new profile is created.

    1. Data features:
  • DynamoDB table with On-Demand capacity, TTL for sessions, PITR backups, S3 export demo.

    1. Edge customization:
  • CloudFront + CloudFront Function adds a security header & redirects //docs.

    1. Performance knobs:
  • Reserved concurrency on hot endpoints, Provisioned Concurrency on the checkout Lambda, and SnapStart (optional) for a Java “payment” Lambda.

    1. (Optional) DB-triggered Lambda from Aurora MySQL or RDS PostgreSQL (data-level trigger pattern).

You’ll finish with:

  • A working set of HTTPS endpoints
  • A Cognito Hosted UI login
  • State machine executions you can watch run
  • CloudWatch logs & metrics you can show in interviews

Architecture (mental map)

[User] ──HTTPS──> [CloudFront]* ──> [API Gateway (REST)]
                                  │
                                  ├─(Cognito Authorizer)──> [Cognito User Pool]
                                  │
                                  └─> [Lambda (proxy)] ──> [DynamoDB: profiles, orders]
                                           │                       │
                                           ├─> DynamoDB Streams ──┘ (Welcome event -> Lambda)
                                           └─> Step Functions (place-order workflow)
                                                   │
                                                   ├─> Lambda: validate
                                                   ├─> Lambda: reserve
                                                   ├─> Lambda: charge (SnapStart optional)
                                                   └─> Lambda: confirm → write order state (DDB)

[EventBridge Rule (cron)] ──> Lambda (cleanup stale orders)

* CloudFront Function at viewer-request: add headers/redirect.
Enter fullscreen mode Exit fullscreen mode

Prereqs

  • AWS account with admin access
  • Region: us-east-1 (helps with CloudFront/edge defaults)
  • Python 3 locally (for testing JWT decode if needed)
  • Node.js optional (if you prefer Node Lambdas)
  • You can do everything in the AWS Console (no CDK/Terraform required for this lab)

Step-by-Step Build

1) DynamoDB tables

Create two tables (Console → DynamoDB → Create table):

  • profiles

    • Partition key: userId (String)
    • Capacity: On-Demand
    • Streams: NEW_AND_OLD_IMAGES (enable)
    • PITR: enable
  • orders

    • Partition key: orderId (String)
    • Capacity: On-Demand
    • TTL attribute (add later): ttlEpoch (Number)
    • PITR: enable

Later: enable Export to S3 from PITR snapshot to demo analytics export.


2) Cognito (auth)

User Pool (sign-in):

  • Console → Cognito → Create user pool
  • Name: serverless-user-pool
  • Username sign-in + email verification (simple defaults)
  • Create an App client (no secret), enable Hosted UI.
  • Note the User Pool ID and App Client ID.

(Optional) Identity Pool (direct AWS access): skip for now; not needed for this API flow.


3) API Gateway (REST)

Create REST API:

  • API Gateway → Create API → REST API → Build
  • Name: serverless-api
  • Endpoint type: Regional
  • Create Resources/Methods:

    • /healthGETLambda proxy (public; no authorizer)
    • /profilesPOST → Lambda proxy (Cognito Authorizer)
    • /profiles/{id}GET → Lambda proxy (Cognito Authorizer)
    • /ordersPOST → Lambda proxy (Cognito Authorizer)
    • /orders/{id}GET → Lambda proxy (Cognito Authorizer)

Cognito Authorizer:

  • Authorizers → Create → Type: Cognito
  • Select your User Pool
  • Attach to the 4 protected methods.

Stage:

  • Deploy API → Stage dev
  • Save the Invoke URL.

4) Lambda functions (Python 3.11)

Create these functions (Console → Lambda → Create function):

A. health-handler (public)

import json
def lambda_handler(event, context):
    return {"statusCode": 200,
            "headers": {"Content-Type":"application/json"},
            "body": json.dumps({"ok": True, "service": "serverless-api"})}
Enter fullscreen mode Exit fullscreen mode

B. profiles-upsert (Cognito-protected)

import os, json, boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('profiles')

def lambda_handler(event, context):
    body = json.loads(event.get("body") or "{}")
    # From JWT (Cognito authorizer), API Gateway puts identity in requestContext
    claims = event["requestContext"]["authorizer"]["claims"]
    user_id = claims.get("sub")  # stable unique id
    item = {
        "userId": user_id,
        "name": body.get("name", ""),
        "email": claims.get("email", ""),
        "updatedAt": event["requestContext"]["requestTimeEpoch"]
    }
    table.put_item(Item=item)
    return {"statusCode": 200, "headers":{"Content-Type":"application/json"},
            "body": json.dumps({"message":"profile upserted","profile": item})}
Enter fullscreen mode Exit fullscreen mode

C. profiles-get (Cognito-protected)

import json, boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('profiles')

def lambda_handler(event, context):
    user_id = event["pathParameters"]["id"]
    resp = table.get_item(Key={"userId": user_id})
    item = resp.get("Item")
    return {"statusCode": 200 if item else 404,
            "headers":{"Content-Type":"application/json"},
            "body": json.dumps(item or {"error":"not found"})}
Enter fullscreen mode Exit fullscreen mode

D. orders-create (starts Step Functions)

import json, os, boto3, uuid, time
sf = boto3.client('stepfunctions')

STATE_MACHINE_ARN = os.environ["STATE_MACHINE_ARN"]

def lambda_handler(event, context):
    order_id = str(uuid.uuid4())
    body = json.loads(event.get("body") or "{}")
    # Pass through claims for personalization/authorization in workflow if needed
    claims = event["requestContext"]["authorizer"]["claims"]
    start_input = {
        "orderId": order_id,
        "userId": claims.get("sub"),
        "amount": body.get("amount", 0),
        "items": body.get("items", [])
    }
    sf.start_execution(stateMachineArn=STATE_MACHINE_ARN,
                       input=json.dumps(start_input))
    return {"statusCode": 202, "headers":{"Content-Type":"application/json"},
            "body": json.dumps({"orderId": order_id, "status":"STARTED"})}
Enter fullscreen mode Exit fullscreen mode

E. orders-get (reads DynamoDB)

import json, boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('orders')

def lambda_handler(event, context):
    order_id = event["pathParameters"]["id"]
    resp = table.get_item(Key={"orderId": order_id})
    item = resp.get("Item")
    return {"statusCode": 200 if item else 404,
            "headers":{"Content-Type":"application/json"},
            "body": json.dumps(item or {"error":"not found"})}
Enter fullscreen mode Exit fullscreen mode

F. Stream trigger: profiles-stream-welcome

  • Trigger: DynamoDB Streams on profiles
import json, logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    for rec in event.get("Records", []):
        if rec["eventName"] == "INSERT":
            new = rec["dynamodb"]["NewImage"]
            logger.info(f"WELCOME: userId={new['userId']['S']} name={new.get('name',{}).get('S','')}")
    return {"statusCode":200, "body":"ok"}
Enter fullscreen mode Exit fullscreen mode

G. Cron cleanup: orders-cleanup (EventBridge hourly)

import json, time, boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('orders')

def lambda_handler(event, context):
    # Example: mark old PENDING orders as EXPIRED (toy logic)
    # In real life you'd use a GSI on status+timestamp and query instead of scan.
    scan = table.scan()
    now = int(time.time())
    updated = 0
    for item in scan.get("Items", []):
        if item.get("status") == "PENDING" and item.get("ttlEpoch", now) < now:
            item["status"] = "EXPIRED"
            table.put_item(Item=item)
            updated += 1
    return {"statusCode": 200, "body": json.dumps({"expired": updated})}
Enter fullscreen mode Exit fullscreen mode

Wire methods → functions in API Gateway (Lambda proxy).
For orders-create, set ENV STATE_MACHINE_ARN (after you create it in next step).

Permissions: each Lambda needs minimal IAM (DynamoDB CRUD to the right tables, Step Functions StartExecution for orders-create, CloudWatch logs). You can attach inline policies per function.


5) Step Functions (Standard)

Console → Step Functions → Create state machine → Write your workflow in ASL:

State machine name: PlaceOrder

Definition (paste):

{
  "Comment": "Place order workflow",
  "StartAt": "Validate",
  "States": {
    "Validate": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {"FunctionName": "validate-order", "Payload.$": "$"},
      "Next": "Reserve",
      "Retry": [{"ErrorEquals":["States.ALL"],"IntervalSeconds":2,"MaxAttempts":2,"BackoffRate":2.0}],
      "Catch": [{"ErrorEquals":["States.ALL"],"ResultPath":"$.error","Next":"Fail"}]
    },
    "Reserve": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {"FunctionName": "reserve-inventory", "Payload.$": "$"},
      "Next": "Charge"
    },
    "Charge": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {"FunctionName": "charge-payment", "Payload.$": "$"},
      "Next": "Confirm"
    },
    "Confirm": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {"FunctionName": "confirm-order", "Payload.$": "$"},
      "End": true
    },
    "Fail": { "Type":"Fail", "Cause":"OrderFailed" }
  }
}
Enter fullscreen mode Exit fullscreen mode

Create four small Lambdas (validate-order, reserve-inventory, charge-payment, confirm-order) that just:

  • Read orderId, userId
  • Update orders table with status: VALIDATEDRESERVEDCHARGEDCONFIRMED

SnapStart (optional): make charge-payment a Java Lambda and enable SnapStart on Published Versions for a cold-start boost. Publish a version and point Step Functions to that version ARN.


6) EventBridge cron

Console → EventBridge → Rules → Create

  • Schedule: rate(1 hour)
  • Target: orders-cleanup Lambda

7) DynamoDB: TTL + Backups + Export

  • orders table → Enable TTL on attribute ttlEpoch
  • PITR already enabled
  • Export: Go to Backups/Exports → Export latest PITR snapshot to S3 (DynamoDB JSON). (For a demo, you can query with Athena after creating a table & schema.)

8) CloudFront + CloudFront Function (edge)

  • Create a small S3 static bucket serverless-docs with an index.html explaining your API.
  • CloudFront distribution → origin = your S3 website or S3 with OAC.
  • CloudFront Function (viewer-request):
function handler(event) {
  var req = event.request;
  if (req.uri === '/') {
    req.uri = '/docs'; // redirect-like rewrite
  }
  // Add a simple security header
  var headers = req.headers;
  headers['x-lab'] = {value: 'serverless-beginner-lab'};
  return req;
}
Enter fullscreen mode Exit fullscreen mode
  • Associate at Viewer Request.

(You’re not calling API Gateway through CloudFront here; this is just to show edge customization. If you want, add another behavior that proxies API to API Gateway.)


9) Concurrency settings

  • For orders-create (front door hot path):

    • Reserved Concurrency = e.g., 50 (protects the account pool)
  • For charge-payment:

    • Provisioned Concurrency = e.g., 5 (low latency & no cold starts)
  • Demonstrate throttling: temporarily set Reserved Concurrency = 0 on health-handler and call it → see 429 ThrottlingException. Restore.


10) (Optional) DB-triggered Lambda (Aurora MySQL / RDS PostgreSQL)

  • Create a tiny table registrations.
  • Add the engine-specific integration/stored proc to invoke Lambda on insert.
  • IAM role on DB cluster allowing lambda:InvokeFunction, and network path to Lambda (public invoke or VPC endpoint/NAT).
  • Insert a row → Lambda logs “WELCOME FROM RDS”.

(This is distinct from RDS **Event Notifications* which are infra-events, not row-level data.)*


How to Test (copy/paste)

A) Health (public)

curl https://{api_id}.execute-api.{region}.amazonaws.com/dev/health
Enter fullscreen mode Exit fullscreen mode

B) Login (Cognito Hosted UI)

  • Go to your User PoolApp client → Open Hosted UI sign-in.
  • Create a test user, sign in, copy the ID token (JWT).

In calls below, set:

AUTH="Authorization: Bearer <ID_TOKEN>"
Enter fullscreen mode Exit fullscreen mode

C) Create Profile

curl -X POST \
 -H "Content-Type: application/json" \
 -H "$AUTH" \
 -d '{"name":"Aisalkyn Aidarova"}' \
 https://{api_id}.execute-api.{region}.amazonaws.com/dev/profiles
Enter fullscreen mode Exit fullscreen mode

D) Get Profile

curl -H "$AUTH" \
 https://{api_id}.execute-api.{region}.amazonaws.com/dev/profiles/<cognito-sub>
Enter fullscreen mode Exit fullscreen mode

cognito-sub is the user’s sub claim (UUID) from the token.

E) Place Order (triggers Step Functions)

curl -X POST \
 -H "Content-Type: application/json" \
 -H "$AUTH" \
 -d '{"amount": 42.50, "items":[{"sku":"A-1","qty":2}]}' \
 https://{api_id}.execute-api.{region}.amazonaws.com/dev/orders
Enter fullscreen mode Exit fullscreen mode

Note returned orderId. Watch the execution in Step Functions Console.

F) Get Order

curl -H "$AUTH" \
 https://{api_id}.execute-api.{region}.amazonaws.com/dev/orders/<orderId>
Enter fullscreen mode Exit fullscreen mode

You should see status progress to CONFIRMED.

G) Streams & Logs

  • Create profile → check CloudWatch logs of profiles-stream-welcome for the WELCOME line.

H) Cron

  • Manually set an orders item with past ttlEpoch and status=PENDING, then wait for the hourly rule (or run the function manually) → it sets EXPIRED.

What to Show as “Final Output”

  • Screenshots:

    • API Gateway stage URLs responding (health, profiles, orders)
    • Cognito Hosted UI login
    • Step Functions graph view with a Succeeded execution
    • DynamoDB tables with items
    • CloudWatch logs: WELCOME stream log
    • EventBridge rule
    • CloudFront Function associated on viewer-request
  • Talking points (interview):

    • Why On-Demand capacity for unpredictable loads
    • TTL & PITR choices
    • Streams → Lambda vs Kinesis use cases
    • Reserved vs Provisioned Concurrency, SnapStart for cold starts
    • Edge customization vs Lambda@Edge
    • Cognito User Pool (auth) vs Identity Pool (AWS access)

Cleanup (to avoid charges)

  • Delete CloudFront distribution (takes time), S3 bucket(s)
  • Delete API Gateway, Lambdas, Step Functions state machine
  • Delete EventBridge rule, DynamoDB tables (stop export jobs first)
  • Delete Cognito User Pool & App client

Top comments (1)

Collapse
 
gulzat_mursakanova_c9f6a7 profile image
Gulzat Mursakanova

thank you for good practice))