The Problem
Brands need to track how customers interact with physical products after purchase: how often products are used, feature engagement patterns, and warranty claim data. Traditional approaches require managing dedicated infrastructure per brand, which doesn't scale.
Requirements:
- Handle NFC tag scans with <200ms response time
- Support thousands of concurrent users during product launches
- Strict data isolation between brands (tenants)
- Real-time analytics dashboards
- Cost-effective at scale
Architecture Overview
Physical Product (NFC Tag)
↓
[Customer Phone]
↓
API Gateway (REST)
↓
Lambda (Request Handler)
↓
DynamoDB (Event Storage)
↓
EventBridge → Lambda (Analytics Processor)
↓
S3 (Aggregated Analytics)
↓
CloudFront (Dashboard)
Technology Decisions
Serverless architecture is ideal for unpredictable traffic patterns like product launches. You only pay for what you use, and the infrastructure automatically scales to handle traffic spikes without any manual intervention.
Why Serverless?
Manual infrastructure:
- Provision servers in advance
- Pay 24/7 even when idle
- Manual scaling configuration
- Patching and maintenance
Zero server management:
- Auto-scales from 0 to thousands
- Pay per request ($0/month idle)
- Built-in redundancy
- No maintenance overhead
Lambda Benefits:
- Zero server management
- Automatic scaling for traffic spikes
- Pay-per-request pricing (ideal for variable usage)
- Built-in redundancy and fault tolerance
DynamoDB Benefits:
- Single-digit millisecond latency at any scale
- Automatic scaling for read/write capacity
- Built-in backup and point-in-time recovery
- Global tables for multi-region support
Trade-offs:
- Cold start latency (mitigated with provisioned concurrency)
- DynamoDB requires careful schema design
- Debugging distributed systems is harder
DynamoDB Schema Design
Multi-tenant data isolation is critical for security and compliance. Use partition keys for physical data separation, validate tenantId on every request, and audit all cross-tenant queries. Schema mistakes in production are expensive to fix.
Multi-Tenant Data Model
Key Requirement: Tenant isolation with efficient queries.
// Table: nfc-events
// Primary Key: PK (Partition Key), SK (Sort Key)
interface NFCEvent {
PK: string; // Format: "TENANT#{tenantId}"
SK: string; // Format: "EVENT#{timestamp}#{eventId}"
GSI1PK: string; // Format: "PRODUCT#{productId}"
GSI1SK: string; // Format: "EVENT#{timestamp}"
tenantId: string;
productId: string;
tagId: string;
eventType: 'scan' | 'interaction' | 'warranty';
userId?: string;
metadata: Record<string, any>;
timestamp: number;
ttl: number; // Auto-delete old events
}
Access Patterns:
- Get all events for a tenant
// Query by PK
PK = "TENANT#abc123"
- Get events for a specific product
// Query GSI1
GSI1PK = "PRODUCT#prod456"
GSI1SK begins_with "EVENT#"
- Time-range queries
// Query with SK condition
PK = "TENANT#abc123"
SK between "EVENT#2024-01-01" and "EVENT#2024-01-31"
Why This Schema Works
Tenant Isolation: Partition key includes tenantId, ensuring data is physically separated.
Efficient Queries: GSI enables querying by product without scanning entire table.
Cost Optimization: TTL automatically deletes old events, reducing storage costs.
Lambda Function Architecture
API Handler
// lambda/api-handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
try {
// Extract tenant from API key or JWT
const tenantId = extractTenantId(event);
// Parse scan event
const body = JSON.parse(event.body || '{}');
const { tagId, productId, eventType, metadata } = body;
// Validate tenant has access to this product
await validateTenantAccess(tenantId, productId);
// Create event record
const timestamp = Date.now();
const eventId = generateEventId();
const item = {
PK: `TENANT#${tenantId}`,
SK: `EVENT#${timestamp}#${eventId}`,
GSI1PK: `PRODUCT#${productId}`,
GSI1SK: `EVENT#${timestamp}`,
tenantId,
productId,
tagId,
eventType,
metadata,
timestamp,
ttl: Math.floor(Date.now() / 1000) + (365 * 24 * 60 * 60) // 1 year
};
// Write to DynamoDB
await docClient.send(
new PutCommand({
TableName: process.env.TABLE_NAME,
Item: item
})
);
// Trigger analytics processing (async)
await publishToEventBridge(item);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
success: true,
eventId
})
};
} catch (error) {
console.error('Error processing NFC event:', error);
return {
statusCode: 500,
body: JSON.stringify({
success: false,
error: 'Failed to process event'
})
};
}
};
Cold Start Optimization
// Reuse connections outside handler
const client = new DynamoDBClient({
maxAttempts: 3,
requestTimeout: 3000
});
// Provisioned concurrency for critical endpoints
// terraform/lambda.tf
resource "aws_lambda_provisioned_concurrency_config" "api" {
function_name = aws_lambda_function.api_handler.function_name
provisioned_concurrent_executions = 10
}
Multi-Tenancy Security
1. API Key Strategy
// Each tenant gets a unique API key
interface APIKey {
key: string;
tenantId: string;
permissions: string[];
rateLimit: number;
}
// API Gateway validates key before reaching Lambda
function extractTenantId(event: APIGatewayProxyEvent): string {
const apiKey = event.headers['x-api-key'];
// API Gateway adds context after validation
return event.requestContext.authorizer?.tenantId;
}
2. Resource Access Control
async function validateTenantAccess(
tenantId: string,
productId: string
): Promise<void> {
// Query product ownership
const result = await docClient.send(
new GetCommand({
TableName: process.env.PRODUCTS_TABLE,
Key: {
PK: `PRODUCT#${productId}`
}
})
);
if (result.Item?.tenantId !== tenantId) {
throw new Error('Unauthorized access to product');
}
}
3. Row-Level Security
DynamoDB partition key includes tenantId, making cross-tenant queries impossible at the database level.
Analytics Processing
Event-Driven Architecture
// lambda/analytics-processor.ts
import { EventBridgeEvent } from 'aws-lambda';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3Client = new S3Client({});
export const handler = async (event: EventBridgeEvent<string, NFCEvent>) => {
const nfcEvent = event.detail;
// Aggregate events by product and time window
const aggregations = await aggregateEvents(nfcEvent);
// Store in S3 for dashboard queries
await s3Client.send(
new PutObjectCommand({
Bucket: process.env.ANALYTICS_BUCKET,
Key: `analytics/${nfcEvent.tenantId}/${nfcEvent.productId}/${getDateKey()}.json`,
Body: JSON.stringify(aggregations),
ContentType: 'application/json'
})
);
};
function aggregateEvents(event: NFCEvent) {
return {
productId: event.productId,
date: new Date(event.timestamp).toISOString().split('T')[0],
metrics: {
totalScans: 1,
uniqueUsers: event.userId ? 1 : 0,
eventTypes: {
[event.eventType]: 1
}
}
};
}
S3 Data Layout
s3://analytics-bucket/
analytics/
{tenantId}/
{productId}/
2024-01-01.json
2024-01-02.json
...
Benefits:
- Partition by tenant for isolation
- Partition by date for time-range queries
- Cheap storage for cold data
- Can use Athena for ad-hoc SQL queries
Analytics Dashboard API
// lambda/dashboard-api.ts
export async function getProductAnalytics(
tenantId: string,
productId: string,
startDate: string,
endDate: string
) {
const s3Keys = generateDateRange(startDate, endDate).map(
date => `analytics/${tenantId}/${productId}/${date}.json`
);
// Fetch all daily aggregations in parallel
const results = await Promise.all(
s3Keys.map(key =>
s3Client.send(
new GetObjectCommand({
Bucket: process.env.ANALYTICS_BUCKET,
Key: key
})
).catch(() => null) // Date might not exist
)
);
// Merge and return
return mergeAnalytics(results.filter(Boolean));
}
Scaling Performance
Achieved Metrics
Response Times:
- P50: 45ms
- P95: 120ms
- P99: 180ms
Throughput:
- 10,000 requests/second sustained
- 50,000 requests/second peak (during product launch)
Concurrent Users:
- Tested up to 15,000 concurrent users
- Zero downtime during traffic spikes
Cost Breakdown (10M requests/month)
- API Gateway: $35/month
- Lambda: $120/month (including provisioned concurrency)
- DynamoDB: $80/month (on-demand pricing)
- S3: $15/month
- Data Transfer: $45/month
- Total: ~$295/month
Cost per 1000 requests: $0.0295
Monitoring & Observability
CloudWatch Metrics
// Custom metrics
import { CloudWatch } from '@aws-sdk/client-cloudwatch';
const cloudwatch = new CloudWatch({});
async function trackCustomMetric(metricName: string, value: number) {
await cloudwatch.putMetricData({
Namespace: 'NFCPlatform',
MetricData: [{
MetricName: metricName,
Value: value,
Unit: 'Count',
Timestamp: new Date()
}]
});
}
Alarms
// terraform/cloudwatch.tf
resource "aws_cloudwatch_metric_alarm" "high_error_rate" {
alarm_name = "nfc-platform-high-errors"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = "2"
metric_name = "Errors"
namespace = "AWS/Lambda"
period = "60"
statistic = "Sum"
threshold = "10"
alarm_description = "This metric monitors lambda errors"
alarm_actions = [aws_sns_topic.alerts.arn]
}
Lessons Learned
1. DynamoDB Schema Design is Critical
Mistake: Initially used productId as partition key. Problem: Hot partitions when a single product went viral. Fix: Use tenantId as partition key, productId in GSI.
2. Lambda Cold Starts Impact UX
Solution:
- Use provisioned concurrency for critical paths
- Optimize bundle size (removed unused AWS SDK modules)
- Keep functions small and focused
3. Multi-Tenant Isolation Requires Discipline
Best Practices:
- Validate tenantId on every request
- Use partition keys for physical isolation
- Audit logs for all cross-tenant queries (should be zero)
4. Event-Driven Architecture Simplifies Scaling
Separating write path (Lambda → DynamoDB) from analytics (EventBridge → S3) allowed independent scaling.
5. Cost Monitoring from Day One
Set up AWS Budgets and cost alerts early. DynamoDB on-demand pricing can surprise you.
Future Improvements
- Global Distribution: DynamoDB Global Tables for multi-region support
- Real-time Analytics: Kinesis Data Streams for live dashboards
- ML Integration: SageMaker for predictive analytics
- GraphQL API: AppSync for more flexible querying
💡Key Takeaways
- 1Serverless (Lambda + DynamoDB) scales effortlessly for unpredictable traffic spikes
- 2Multi-tenancy requires careful schema design - use partition keys for data isolation
- 3Event-driven architecture with EventBridge separates concerns and enables independent scaling
- 4DynamoDB delivers sub-200ms response times but schema mistakes are expensive to fix
- 5Monitoring and cost tracking from day one - AWS costs can surprise you
- 6Provisioned concurrency eliminates Lambda cold starts for critical paths
- 7TTL (Time To Live) automatically cleans up old data and reduces costs
