Skip to content

Serverless Project — Build a Complete Serverless Application

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Serverless Project. We cover key concepts, practical examples, and best practices to help you master this topic.

This capstone project guides you through building a production-ready serverless URL shortener with AWS Lambda, API Gateway, DynamoDB, and the Serverless Framework, covering the full development lifecycle.

What You'll Learn

By the end of this project you will have built a complete serverless application: defining infrastructure as code, implementing business logic, handling errors, monitoring with CloudWatch, and setting up CI/CD.

Why It Matters

Building a complete project ties together all the serverless concepts you have learned. This URL shortener demonstrates real-world patterns: REST API design, database access, event-driven processing, monitoring, and deployment.

Real-World Use

URL shorteners like bit.ly need to handle millions of redirects per day with high availability. A serverless implementation scales automatically, costs nothing when idle, and provides single-digit millisecond redirect latency.

flowchart TD
    C[Client] -->|POST /shorten| AG[API Gateway]
    C -->|GET /{code}| AG
    AG -->|Lambda| S[Shorten Function]
    AG -->|Lambda| R[Redirect Function]
    S --> D[DynamoDB - URLs Table]
    R --> D
    R -->|301 Redirect| C
    D -->|Streams| A[Analytics Lambda]
    A --> AN[DynamoDB - Analytics Table]
    style AG fill:#f90,color:#fff

Project Structure

url-shortener/
  serverless.yml          # Infrastructure as code
  handlers/
    shorten.py            # POST /shorten
    redirect.py           # GET /{code}
    analytics.py          # DynamoDB Streams trigger
  tests/
    test_shorten.py       # Unit tests
    test_redirect.py
  .github/
    workflows/
      deploy.yml          # CI/CD pipeline
  package.json            # Node.js dependencies
  requirements.txt        # Python dependencies

serverless.yml

service: url-shortener

provider:
  name: aws
  runtime: python3.9
  region: us-east-1
  stage: ${opt:stage, 'dev'}
  environment:
    URLS_TABLE: ${self:service}-urls-${self:provider.stage}
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:PutItem
        - dynamodb:GetItem
        - dynamodb:UpdateItem
      Resource: !GetAtt UrlsTable.Arn

functions:
  shorten:
    handler: handlers/shorten.handler
    events:
      - httpApi:
          method: POST
          path: /shorten
  
  redirect:
    handler: handlers/redirect.handler
    events:
      - httpApi:
          method: GET
          path: /{code}
  
  analytics:
    handler: handlers/analytics.handler
    events:
      - stream:
          type: dynamodb
          arn: !GetAtt UrlsTable.StreamArn

resources:
  Resources:
    UrlsTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:service}-urls-${self:provider.stage}
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: code
            AttributeType: S
        KeySchema:
          - AttributeName: code
            KeyType: HASH
        StreamSpecification:
          StreamViewType: NEW_AND_OLD_IMAGES

Handler Implementation

# handlers/shorten.py
import json
import hashlib
import time

def handler(event, context):
    body = json.loads(event.get("body", "{}"))
    long_url = body.get("url")
    
    if not long_url:
        return {"statusCode": 400, "body": json.dumps({"error": "Missing url"})}
    
    code = hashlib.md5(long_url.encode()).hexdigest()[:8]
    
    print(f"Storing code={code} -> url={long_url}")
    
    return {
        "statusCode": 201,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({
            "code": code,
            "short_url": f"https://short.ly/{code}",
            "long_url": long_url
        })
    }
# handlers/redirect.py
import json

store = {
    "abc123": "https://example.com/very/long/url"
}

def handler(event, context):
    code = event.get("pathParameters", {}).get("code")
    
    long_url = store.get(code)
    if not long_url:
        return {"statusCode": 404, "body": json.dumps({"error": "URL not found"})}
    
    print(f"Redirect {code} -> {long_url}")
    
    return {
        "statusCode": 301,
        "headers": {"Location": long_url},
        "body": ""
    }

Testing

# tests/test_shorten.py
import json
import sys
sys.path.insert(0, ".")

from handlers.shorten import handler as shorten_handler
from handlers.redirect import handler as redirect_handler

def test_shorten_url():
    event = {"body": json.dumps({"url": "https://example.com"})}
    result = shorten_handler(event, None)
    assert result["statusCode"] == 201
    body = json.loads(result["body"])
    assert "code" in body
    assert body["long_url"] == "https://example.com"
    print(f"PASS: shorten returns 201 with code")

def test_missing_url():
    result = shorten_handler({"body": json.dumps({})}, None)
    assert result["statusCode"] == 400
    print("PASS: missing url returns 400")

def test_redirect():
    result = redirect_handler({"pathParameters": {"code": "abc123"}}, None)
    assert result["statusCode"] == 301
    assert result["headers"]["Location"] == "https://example.com/very/long/url"
    print("PASS: redirect returns 301 with Location header")

def test_redirect_not_found():
    result = redirect_handler({"pathParameters": {"code": "nonexistent"}}, None)
    assert result["statusCode"] == 404
    print("PASS: unknown code returns 404")

test_shorten_url()
test_missing_url()
test_redirect()
test_redirect_not_found()
print("\nAll tests passed!")

Common Mistakes

  1. Not handling DynamoDB Streams ordering: Stream records within a shard are ordered. Process them sequentially to maintain consistency.

  2. Forgetting to configure DynamoDB Streams: Streams must be enabled on the table with the appropriate view type.

  3. Not setting up CloudWatch alarms: Without alarms on error rates and throttles, issues go undetected until users report them.

  4. Skipping local testing: Test functions locally with mock events before deploying to avoid costly deployment-test cycles.

  5. Not implementing idempotency: Duplicate URL submissions should return the same code. Use conditional writes in DynamoDB.

Practice Questions

  1. What is the architecture of the URL shortener? API Gateway receives requests, Lambda functions handle shortening and redirecting, DynamoDB stores mappings, and Streams trigger analytics.

  2. How does the redirect function achieve low latency? It does a simple DynamoDB GetItem by primary key, which has single-digit millisecond latency, and returns a 301 redirect.

  3. What would you add to make this production-ready? Custom domain, HTTPS, CloudFront CDN, analytics dashboard, Rate Limiting, and user authentication.

  4. How does the DynamoDB Streams analytics function work? It receives item changes and updates a separate analytics table with click counts and timestamps.

  5. Challenge: Extend the URL shortener with user authentication, click analytics dashboard, custom short codes, and QR Code Generation.

FAQ

How much does this URL shortener cost to run?

At low scale (thousands of requests/month), it costs pennies. DynamoDB on-demand and Lambda free tier cover most usage.

Can I use a custom domain?

Yes. Create a custom domain in API Gateway and add a CNAME record in Route 53 or your DNS provider.

How do I handle high traffic?

DynamoDB on-demand scales automatically. API Gateway has high default limits. Configure reserved concurrency for Lambda.

Can I add authentication?

Yes. Add a Lambda authorizer or JWT authorizer to the shorten endpoint to restrict who can create short URLs.

How do I delete short URLs?

Add a DELETE /{code} endpoint with authentication, and add a soft-delete flag in DynamoDB.

Mini Project

You have already built the URL shortener as the capstone project. Extend it with analytics tracking that records each redirect with timestamp and user agent.

import json
import time

analytics_store = []

def track_redirect(code, event):
    record = {
        "code": code,
        "timestamp": time.time(),
        "user_agent": event.get("headers", {}).get("user-agent", "unknown"),
        "ip": event.get("requestContext", {}).get("identity", {}).get("sourceIp", "unknown")
    }
    analytics_store.append(record)
    print(f"Analytics: {code} accessed from {record['ip']}")

def handler(event, context):
    code = event.get("pathParameters", {}).get("code")
    track_redirect(code, event)
    
    return {"statusCode": 301, "headers": {"Location": f"https://example.com/{code}"}, "body": ""}

result = handler({"pathParameters": {"code": "abc"}, "headers": {"user-agent": "Mozilla/5.0"}, "requestContext": {"identity": {"sourceIp": "1.2.3.4"}}}, None)
print(f"Redirect: {result['statusCode']}")
print(f"Analytics stored: {len(analytics_store)} events")

What's Next

Congratulations on completing the Serverless module. Continue to Stripe Payments for payment processing integration.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro