A production-grade serverless URL shortener built with AWS Lambda, API Gateway, and DynamoDB. Features AI-powered smart short code generation using Google Gemini API.
POST /shorten GET /{shortCode}
│ │
▼ ▼
API Gateway API Gateway
│ │
▼ ▼
ShortenUrlFunction RedirectFunction
│ │
├── Gemini AI (smart code) ▼
├── Random fallback DynamoDB Lookup
└── DynamoDB Save │
▼
302 Redirect → Original URL
- Atomic Collision Handling — DynamoDB conditional writes ensure two simultaneous requests never overwrite each other, eliminating race conditions without application-level locking
- Graceful Degradation — AI code generation fails silently with automatic fallback to random codes, ensuring 100% uptime regardless of external API availability
- Singleton Pattern — DynamoDB client initialised once per Lambda container lifecycle, not per request, reducing latency by ~200ms
- PAY_PER_REQUEST billing — scales to zero cost at zero traffic, ideal for variable workloads
- ARM64 (Graviton) — 20% cheaper and faster than x86 Lambda
- DynamoDB TTL — automatic URL expiry without cron jobs or cleanup Lambda functions
- GSI on userId + createdAt — pre-built index for user history feature, sorted chronologically, zero query cost at read time
- Smart short codes — Gemini AI generates meaningful codes ("lambda-gs") instead of random strings ("x4ongp")
- Timeout-bounded — AI calls capped at 2 seconds, protecting API response time SLA
- Output validation — AI responses validated before use, rejecting truncated or malformed suggestions
| Service | Purpose |
|---|---|
| AWS Lambda | Serverless compute for business logic |
| API Gateway | HTTP endpoints routing |
| DynamoDB | NoSQL database for URL mappings |
| DynamoDB TTL | Automatic URL expiry |
| DynamoDB GSI | Query URLs by user (future auth) |
- Repository Pattern — all DynamoDB operations isolated in
UrlRepository - Graceful Degradation — AI failure falls back to random code generation
- Conditional Writes — atomic collision handling in DynamoDB
- Singleton Pattern — DynamoDB client initialised once per Lambda instance
- Environment-based Config — local vs production via environment variables
Install these tools in order:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"brew install --cask temurin@21
java -version # should show openjdk 21brew install maven
mvn -versionbrew install awscli
aws --version
# Configure with dummy credentials for local development
aws configure
# AWS Access Key ID: test
# AWS Secret Access Key: test
# Default region name: us-east-1
# Default output format: jsonbrew tap aws/tap
brew install aws-sam-cli
sam --versionDownload from: https://www.docker.com/products/docker-desktop/
- Choose Apple Silicon for M1/M2/M3/M4 Macs
- Open Docker Desktop and wait for "Engine running"
docker --versionbrew install --cask intellij-idea-ceurl-shortener/
├── ShortenUrlFunction/
│ ├── pom.xml # Maven dependencies
│ └── src/main/java/com/urlshortener/
│ ├── handler/
│ │ ├── ShortenUrlHandler.java # POST /shorten Lambda
│ │ └── RedirectHandler.java # GET /{shortCode} Lambda
│ ├── model/
│ │ └── UrlMapping.java # DynamoDB table model
│ ├── repository/
│ │ └── UrlRepository.java # All DynamoDB operations
│ └── util/
│ ├── ShortCodeGenerator.java # Random code generation
│ └── AiCodeSuggester.java # Gemini AI integration
├── events/ # Sample Lambda events for testing
├── template.yaml # SAM/CloudFormation infrastructure
├── env.json # Local environment variables (gitignored)
├── start-local.sh # One-command local startup script
└── .gitignore
git clone <your-repo-url>
cd url-shortener
open -a "IntelliJ IDEA CE" .Create env.json in the project root:
{
"ShortenUrlFunction": {
"DYNAMODB_ENDPOINT": "http://host.docker.internal:8000",
"TABLE_NAME": "url-shortener",
"DOMAIN_BASE": "http://localhost:3000",
"GEMINI_API_KEY": "your-gemini-api-key-here"
},
"RedirectFunction": {
"DYNAMODB_ENDPOINT": "http://host.docker.internal:8000",
"TABLE_NAME": "url-shortener",
"DOMAIN_BASE": "http://localhost:3000"
}
}Get your Gemini API key from: https://aistudio.google.com/apikey
Use model: gemini-2.5-flash (free tier: 20 requests/day)
# Make script executable (first time only)
chmod +x start-local.sh
# Start DynamoDB Local + create table
./start-local.shsam build
sam local start-api --env-vars env.jsonAPI is now running at http://localhost:3000
Request:
# Random code (AI-generated when possible)
curl -X POST http://localhost:3000/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://aws.amazon.com/lambda/getting-started"}'
# Custom alias
curl -X POST http://localhost:3000/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com", "alias": "my-github"}'Response (201 Created):
{
"shortCode": "lambda-gs",
"shortUrl": "http://localhost:3000/lambda-gs",
"originalUrl": "https://aws.amazon.com/lambda/getting-started"
}Error Responses:
| Status | Meaning |
|---|---|
| 400 | Missing URL, invalid alias format, or alias too short/long |
| 409 | Custom alias already taken |
| 500 | Internal error (retry) |
curl -v http://localhost:3000/lambda-gs
# Returns: HTTP 302 with Location: https://aws.amazon.com/lambda/getting-startedError Responses:
| Status | Meaning |
|---|---|
| 404 | Short code not found or expired |
| 500 | Internal error |
- Length: 3–30 characters
- Allowed characters: letters (a-z, A-Z), numbers (0-9), hyphens (-), underscores (_)
- Cannot start or end with a hyphen
- Cannot contain consecutive hyphens
Table: url-shortener
| Attribute | Type | Description |
|---|---|---|
shortCode |
String (PK) | Unique short code / alias |
originalUrl |
String | Full destination URL |
createdAt |
String | ISO 8601 creation timestamp |
userId |
String | Owner (anonymous until auth added) |
ttl |
Number | Unix epoch expiry (DynamoDB TTL) |
GSI: UserUrlsIndex
- Partition Key:
userId - Sort Key:
createdAt - Purpose: query all URLs by user, sorted by date
Uses Google Gemini API (gemini-2.5-flash) to generate meaningful short codes:
Input: https://aws.amazon.com/lambda/getting-started
Output: lambda-gs (instead of random "x4ongp")
Fallback chain:
AI available + valid response → use AI code
AI unavailable / timeout → random 6-char alphanumeric code
AI code already taken → random 6-char alphanumeric code
AI is optional — the service works fully without a Gemini API key.
DynamoDB connection refused:
# Check if container is running
docker ps | grep dynamodb
# Restart if stopped
docker start dynamodb-local
# Recreate table if data was lost (in-memory storage resets on restart)
./start-local.shSAM env vars not loading:
- Ensure all variables are declared in
template.yamlGlobals section env.jsononly overrides declared variables, it cannot inject new ones
Java version mismatch error:
# Verify Java 21 is active
java -version
# If wrong version, set Java 21
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
export PATH=$JAVA_HOME/bin:$PATHPort 3000 already in use:
lsof -i :3000
kill -9 <PID>Local development: $0 (all services run locally via Docker + SAM)
AWS deployment (when ready):
| Service | Free Tier | Expected Monthly Cost |
|---|---|---|
| Lambda | 1M requests/month | ~$0 |
| API Gateway | 1M calls/month | ~$0 |
| DynamoDB | 25GB + 25 WCU/RCU | ~$0 |
| Total | ~$0 |
- Never commit
env.json— it contains API keys - In production, use AWS Secrets Manager for API keys
- API Gateway + Lambda IAM roles follow least-privilege principle
- DynamoDB access scoped to specific table only
- User authentication with AWS Cognito
- View all URLs created by user (history)
- Click analytics tracking
- URL safety scanning
- Custom domain support
- Rate limiting per user