Serverless Architecture with AWS Lambda and CDK
Building scalable, cost-effective serverless applications with AWS Lambda, API Gateway, EventBridge, and Step Functions using Infrastructure as Code
Introduction
Serverless architecture has revolutionized how we build and deploy applications. By abstracting away server management, developers can focus on business logic while benefiting from automatic scaling, pay-per-use pricing, and reduced operational overhead.
This comprehensive guide explores building production-ready serverless applications on AWS using Lambda, API Gateway, EventBridge, and Step Functions, all defined with AWS CDK in TypeScript. You’ll learn architectural patterns, best practices, and see real-world code examples that you can adapt for your projects.
What You’ll Learn:
- Serverless architecture fundamentals and when to use it
- AWS Lambda best practices for performance and cost optimization
- Building REST and GraphQL APIs with API Gateway
- Event-driven architectures with EventBridge
- Orchestrating complex workflows with Step Functions
- Complete CDK patterns for serverless applications
- Testing, monitoring, and observability strategies
Understanding Serverless Architecture
What is Serverless?
Serverless doesn’t mean “no servers” – it means you don’t manage servers. The cloud provider handles provisioning, scaling, patching, and maintaining the underlying infrastructure.
Key Characteristics:
- No server management: Focus on code, not infrastructure
- Automatic scaling: From zero to thousands of concurrent executions
- Pay-per-use: Only pay for actual compute time consumed
- Built-in high availability: Multi-AZ deployment by default
- Event-driven: Triggered by events from various sources
When to Use Serverless
Ideal Use Cases:
- REST APIs and GraphQL backends
- Event processing (S3 uploads, DynamoDB streams, etc.)
- Scheduled tasks and cron jobs
- Data transformation and ETL pipelines
- Webhooks and integrations
- Microservices architectures
- Real-time file processing
- IoT backends
When to Consider Alternatives:
- Long-running processes (>15 minutes)
- Applications requiring consistent sub-millisecond latency
- Stateful applications with in-memory state
- Legacy applications with significant refactoring needs
- Workloads with constant high traffic (might be more cost-effective on EC2/containers)
AWS Serverless Services Ecosystem
Compute: Lambda, Fargate
API: API Gateway, AppSync (GraphQL)
Event Bus: EventBridge
Orchestration: Step Functions
Storage: S3, DynamoDB
Messaging: SQS, SNS
Authentication: Cognito
AWS Lambda Best Practices
Lambda Function Structure
Recommended Project Structure:
my-serverless-app/
├── src/
│ ├── functions/
│ │ ├── api/
│ │ │ ├── get-user.ts
│ │ │ ├── create-user.ts
│ │ │ └── update-user.ts
│ │ ├── events/
│ │ │ ├── process-order.ts
│ │ │ └── send-email.ts
│ │ └── scheduled/
│ │ └── daily-report.ts
│ ├── lib/
│ │ ├── database.ts
│ │ ├── validation.ts
│ │ └── utils.ts
│ └── types/
│ └── index.ts
├── lib/
│ ├── lambda-stack.ts
│ └── api-stack.ts
└── package.json
Cold Start Optimization
1. Choose the Right Runtime:
// Best cold start performance
Runtime.NODEJS_20_X // ~100-200ms
Runtime.PYTHON_3_11 // ~150-250ms
// Slower cold starts
Runtime.JAVA_21 // ~1-3 seconds
Runtime.DOTNET_8 // ~500ms-1s
2. Minimize Package Size:
// lambda/get-user.ts
// ❌ Bad: Import entire AWS SDK
import AWS from 'aws-sdk';
// ✅ Good: Import only what you need
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
3. Use Lambda Layers for Dependencies:
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
export class LambdaLayerStack extends cdk.Stack {
public readonly sharedLayer: lambda.LayerVersion;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Create shared dependencies layer
this.sharedLayer = new lambda.LayerVersion(this, 'SharedDependencies', {
code: lambda.Code.fromAsset('layers/shared-dependencies'),
compatibleRuntimes: [lambda.Runtime.NODEJS_20_X],
description: 'Shared dependencies for all Lambda functions',
});
}
}
4. Enable SnapStart (Java/.NET):
const javaFunction = new lambda.Function(this, 'JavaFunction', {
runtime: lambda.Runtime.JAVA_21,
handler: 'com.example.Handler',
code: lambda.Code.fromAsset('target/function.jar'),
snapStart: lambda.SnapStartConf.ON_PUBLISHED_VERSIONS, // Reduces cold starts by 10x
});
Memory and Timeout Configuration
Memory Configuration:
// Memory affects both CPU and cost
// More memory = More CPU = Faster execution
// Low memory for simple tasks
const simpleFunction = new lambda.Function(this, 'SimpleFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda/simple'),
memorySize: 128, // MB - minimum, cheapest
timeout: cdk.Duration.seconds(3),
});
// Medium memory for typical APIs
const apiFunction = new lambda.Function(this, 'ApiFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda/api'),
memorySize: 512, // MB - good balance
timeout: cdk.Duration.seconds(10),
});
// High memory for compute-intensive tasks
const computeFunction = new lambda.Function(this, 'ComputeFunction', {
runtime: lambda.Runtime.PYTHON_3_11,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda/compute'),
memorySize: 3008, // MB - maximum CPU
timeout: cdk.Duration.seconds(60),
});
Pro Tip: Use AWS Lambda Power Tuning to find optimal memory configuration.
Environment Variables and Configuration
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as ssm from 'aws-cdk-lib/aws-ssm';
// ✅ Good: Use environment variables for configuration
const apiFunction = new lambda.Function(this, 'ApiFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda/api'),
environment: {
TABLE_NAME: table.tableName,
REGION: this.region,
LOG_LEVEL: 'INFO',
// Secrets should be in Secrets Manager, not env vars
API_URL: 'https://api.example.com',
},
});
// For secrets, use AWS Secrets Manager
const secret = secretsmanager.Secret.fromSecretNameV2(
this,
'ApiKey',
'prod/api/key'
);
secret.grantRead(apiFunction);
// Access in Lambda code:
// const secretValue = await secretsManager.getSecretValue({ SecretId: process.env.SECRET_ARN });
Error Handling and Retries
// lambda/handlers/process-order.ts
import { SQSEvent, Context } from 'aws-lambda';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
const dynamodb = new DynamoDBClient({});
export const handler = async (event: SQSEvent, context: Context) => {
// Array to track failed messages
const batchItemFailures: Array<{ itemIdentifier: string }> = [];
for (const record of event.Records) {
try {
const order = JSON.parse(record.body);
// Validate input
if (!order.orderId || !order.customerId) {
throw new Error('Invalid order format');
}
// Process order
await dynamodb.send(
new PutItemCommand({
TableName: process.env.TABLE_NAME!,
Item: {
PK: { S: `ORDER#${order.orderId}` },
SK: { S: `CUSTOMER#${order.customerId}` },
status: { S: 'PROCESSED' },
timestamp: { N: Date.now().toString() },
},
})
);
console.log(`Successfully processed order: ${order.orderId}`);
} catch (error) {
console.error(`Failed to process message: ${record.messageId}`, error);
// Return failed message ID for SQS to retry
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
// Return partial batch response
return { batchItemFailures };
};
Configure Dead Letter Queue (DLQ):
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
// Create DLQ for failed messages
const dlq = new sqs.Queue(this, 'OrderProcessingDLQ', {
queueName: 'order-processing-dlq',
retentionPeriod: cdk.Duration.days(14),
});
// Create main queue
const queue = new sqs.Queue(this, 'OrderQueue', {
queueName: 'order-processing-queue',
visibilityTimeout: cdk.Duration.seconds(300),
deadLetterQueue: {
queue: dlq,
maxReceiveCount: 3, // Retry 3 times before sending to DLQ
},
});
// Lambda function
const processOrderFunction = new lambda.Function(this, 'ProcessOrder', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'process-order.handler',
code: lambda.Code.fromAsset('lambda/handlers'),
timeout: cdk.Duration.seconds(30),
});
// Add SQS as event source
processOrderFunction.addEventSource(
new lambdaEventSources.SqsEventSource(queue, {
batchSize: 10,
reportBatchItemFailures: true, // Enable partial batch responses
})
);
Building REST APIs with API Gateway and Lambda
Complete API Stack with CDK
// lib/api-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
export class UserApiStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// DynamoDB table for user data
const userTable = new dynamodb.Table(this, 'UserTable', {
tableName: 'users',
partitionKey: { name: 'userId', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// Lambda Layer for shared code
const sharedLayer = new lambda.LayerVersion(this, 'SharedLayer', {
code: lambda.Code.fromAsset('layers/shared'),
compatibleRuntimes: [lambda.Runtime.NODEJS_20_X],
description: 'Shared utilities and AWS SDK',
});
// Lambda functions
const getUser = new lambda.Function(this, 'GetUser', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'get-user.handler',
code: lambda.Code.fromAsset('lambda/api'),
layers: [sharedLayer],
environment: {
TABLE_NAME: userTable.tableName,
},
timeout: cdk.Duration.seconds(10),
memorySize: 512,
});
const createUser = new lambda.Function(this, 'CreateUser', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'create-user.handler',
code: lambda.Code.fromAsset('lambda/api'),
layers: [sharedLayer],
environment: {
TABLE_NAME: userTable.tableName,
},
timeout: cdk.Duration.seconds(10),
memorySize: 512,
});
const updateUser = new lambda.Function(this, 'UpdateUser', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'update-user.handler',
code: lambda.Code.fromAsset('lambda/api'),
layers: [sharedLayer],
environment: {
TABLE_NAME: userTable.tableName,
},
timeout: cdk.Duration.seconds(10),
memorySize: 512,
});
const deleteUser = new lambda.Function(this, 'DeleteUser', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'delete-user.handler',
code: lambda.Code.fromAsset('lambda/api'),
layers: [sharedLayer],
environment: {
TABLE_NAME: userTable.tableName,
},
timeout: cdk.Duration.seconds(10),
memorySize: 512,
});
// Grant DynamoDB permissions
userTable.grantReadData(getUser);
userTable.grantWriteData(createUser);
userTable.grantWriteData(updateUser);
userTable.grantWriteData(deleteUser);
// CloudWatch Logs
const logGroup = new logs.LogGroup(this, 'ApiLogs', {
logGroupName: '/aws/apigateway/user-api',
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// API Gateway REST API
const api = new apigateway.RestApi(this, 'UserApi', {
restApiName: 'User API',
description: 'API for user management',
deployOptions: {
stageName: 'prod',
loggingLevel: apigateway.MethodLoggingLevel.INFO,
dataTraceEnabled: true,
metricsEnabled: true,
accessLogDestination: new apigateway.LogGroupLogDestination(logGroup),
accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(),
},
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: apigateway.Cors.ALL_METHODS,
allowHeaders: ['Content-Type', 'Authorization'],
},
});
// Request validator
const requestValidator = new apigateway.RequestValidator(
this,
'RequestValidator',
{
restApi: api,
validateRequestBody: true,
validateRequestParameters: true,
}
);
// Request models
const userModel = api.addModel('UserModel', {
contentType: 'application/json',
schema: {
type: apigateway.JsonSchemaType.OBJECT,
required: ['email', 'name'],
properties: {
email: {
type: apigateway.JsonSchemaType.STRING,
format: 'email',
},
name: {
type: apigateway.JsonSchemaType.STRING,
minLength: 1,
maxLength: 100,
},
age: {
type: apigateway.JsonSchemaType.INTEGER,
minimum: 0,
maximum: 150,
},
},
},
});
// API Resources and Methods
const users = api.root.addResource('users');
// POST /users - Create user
users.addMethod('POST', new apigateway.LambdaIntegration(createUser), {
requestValidator,
requestModels: {
'application/json': userModel,
},
});
// GET /users/{userId} - Get user
const user = users.addResource('{userId}');
user.addMethod('GET', new apigateway.LambdaIntegration(getUser), {
requestParameters: {
'method.request.path.userId': true,
},
});
// PUT /users/{userId} - Update user
user.addMethod('PUT', new apigateway.LambdaIntegration(updateUser), {
requestValidator,
requestModels: {
'application/json': userModel,
},
requestParameters: {
'method.request.path.userId': true,
},
});
// DELETE /users/{userId} - Delete user
user.addMethod('DELETE', new apigateway.LambdaIntegration(deleteUser), {
requestParameters: {
'method.request.path.userId': true,
},
});
// Outputs
new cdk.CfnOutput(this, 'ApiUrl', {
value: api.url,
description: 'User API URL',
});
new cdk.CfnOutput(this, 'TableName', {
value: userTable.tableName,
description: 'DynamoDB table name',
});
}
}
Lambda Handler Implementation
// lambda/api/get-user.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
const TABLE_NAME = process.env.TABLE_NAME!;
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
};
try {
const userId = event.pathParameters?.userId;
if (!userId) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: 'userId is required' }),
};
}
// Get user from DynamoDB
const result = await dynamodb.send(
new GetItemCommand({
TableName: TABLE_NAME,
Key: {
userId: { S: userId },
},
})
);
if (!result.Item) {
return {
statusCode: 404,
headers,
body: JSON.stringify({ error: 'User not found' }),
};
}
const user = unmarshall(result.Item);
return {
statusCode: 200,
headers,
body: JSON.stringify(user),
};
} catch (error) {
console.error('Error fetching user:', error);
return {
statusCode: 500,
headers,
body: JSON.stringify({ error: 'Internal server error' }),
};
}
};
// lambda/api/create-user.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
import { v4 as uuidv4 } from 'uuid';
const dynamodb = new DynamoDBClient({});
const TABLE_NAME = process.env.TABLE_NAME!;
interface CreateUserRequest {
email: string;
name: string;
age?: number;
}
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
};
try {
if (!event.body) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: 'Request body is required' }),
};
}
const userData: CreateUserRequest = JSON.parse(event.body);
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userData.email)) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: 'Invalid email format' }),
};
}
const userId = uuidv4();
const timestamp = new Date().toISOString();
const user = {
userId,
email: userData.email,
name: userData.name,
age: userData.age,
createdAt: timestamp,
updatedAt: timestamp,
};
// Save to DynamoDB
await dynamodb.send(
new PutItemCommand({
TableName: TABLE_NAME,
Item: marshall(user),
ConditionExpression: 'attribute_not_exists(userId)',
})
);
return {
statusCode: 201,
headers,
body: JSON.stringify(user),
};
} catch (error) {
console.error('Error creating user:', error);
return {
statusCode: 500,
headers,
body: JSON.stringify({ error: 'Internal server error' }),
};
}
};
Event-Driven Architecture with EventBridge
EventBridge Pattern
// lib/event-driven-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { Construct } from 'constructs';
export class EventDrivenStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Custom EventBridge Event Bus
const eventBus = new events.EventBus(this, 'OrderEventBus', {
eventBusName: 'order-events',
});
// Lambda functions for different event handlers
const sendEmailFunction = new lambda.Function(this, 'SendEmail', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'send-email.handler',
code: lambda.Code.fromAsset('lambda/events'),
timeout: cdk.Duration.seconds(30),
});
const updateInventoryFunction = new lambda.Function(this, 'UpdateInventory', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'update-inventory.handler',
code: lambda.Code.fromAsset('lambda/events'),
timeout: cdk.Duration.seconds(30),
});
const notifyWarehouseFunction = new lambda.Function(this, 'NotifyWarehouse', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'notify-warehouse.handler',
code: lambda.Code.fromAsset('lambda/events'),
timeout: cdk.Duration.seconds(30),
});
// DLQ for failed events
const dlq = new sqs.Queue(this, 'EventsDLQ', {
queueName: 'order-events-dlq',
retentionPeriod: cdk.Duration.days(14),
});
// Rule: Order Placed
const orderPlacedRule = new events.Rule(this, 'OrderPlacedRule', {
eventBus,
ruleName: 'order-placed',
description: 'Trigger when a new order is placed',
eventPattern: {
source: ['order.service'],
detailType: ['Order Placed'],
},
});
// Add multiple targets to the rule
orderPlacedRule.addTarget(
new targets.LambdaFunction(sendEmailFunction, {
deadLetterQueue: dlq,
maxEventAge: cdk.Duration.hours(2),
retryAttempts: 2,
})
);
orderPlacedRule.addTarget(
new targets.LambdaFunction(updateInventoryFunction, {
deadLetterQueue: dlq,
maxEventAge: cdk.Duration.hours(2),
retryAttempts: 2,
})
);
orderPlacedRule.addTarget(
new targets.LambdaFunction(notifyWarehouseFunction, {
deadLetterQueue: dlq,
maxEventAge: cdk.Duration.hours(2),
retryAttempts: 2,
})
);
// Rule: High Value Order (>$1000)
const highValueOrderRule = new events.Rule(this, 'HighValueOrderRule', {
eventBus,
ruleName: 'high-value-order',
description: 'Trigger for orders over $1000',
eventPattern: {
source: ['order.service'],
detailType: ['Order Placed'],
detail: {
amount: [{ numeric: ['>', 1000] }],
},
},
});
// SNS topic for high-value order alerts
const alertTopic = new cdk.aws_sns.Topic(this, 'HighValueAlertTopic');
highValueOrderRule.addTarget(new targets.SnsTopic(alertTopic));
// Output
new cdk.CfnOutput(this, 'EventBusArn', {
value: eventBus.eventBusArn,
description: 'Order Event Bus ARN',
});
}
}
Publishing Events
// lambda/api/create-order.ts
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
const eventbridge = new EventBridgeClient({});
const EVENT_BUS_NAME = process.env.EVENT_BUS_NAME!;
interface Order {
orderId: string;
customerId: string;
amount: number;
items: Array<{ productId: string; quantity: number }>;
}
export const publishOrderPlacedEvent = async (order: Order): Promise<void> => {
await eventbridge.send(
new PutEventsCommand({
Entries: [
{
EventBusName: EVENT_BUS_NAME,
Source: 'order.service',
DetailType: 'Order Placed',
Detail: JSON.stringify({
orderId: order.orderId,
customerId: order.customerId,
amount: order.amount,
items: order.items,
timestamp: new Date().toISOString(),
}),
},
],
})
);
console.log(`Published Order Placed event for order: ${order.orderId}`);
};
Orchestrating Workflows with Step Functions
Step Functions State Machine
// lib/workflow-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
export class OrderWorkflowStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Lambda functions for each step
const validateOrder = new lambda.Function(this, 'ValidateOrder', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'validate-order.handler',
code: lambda.Code.fromAsset('lambda/workflow'),
timeout: cdk.Duration.seconds(30),
});
const processPayment = new lambda.Function(this, 'ProcessPayment', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'process-payment.handler',
code: lambda.Code.fromAsset('lambda/workflow'),
timeout: cdk.Duration.seconds(30),
});
const reserveInventory = new lambda.Function(this, 'ReserveInventory', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'reserve-inventory.handler',
code: lambda.Code.fromAsset('lambda/workflow'),
timeout: cdk.Duration.seconds(30),
});
const notifyCustomer = new lambda.Function(this, 'NotifyCustomer', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'notify-customer.handler',
code: lambda.Code.fromAsset('lambda/workflow'),
timeout: cdk.Duration.seconds(30),
});
const refundPayment = new lambda.Function(this, 'RefundPayment', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'refund-payment.handler',
code: lambda.Code.fromAsset('lambda/workflow'),
timeout: cdk.Duration.seconds(30),
});
// Step Functions tasks
const validateTask = new tasks.LambdaInvoke(this, 'Validate Order', {
lambdaFunction: validateOrder,
outputPath: '$.Payload',
});
const paymentTask = new tasks.LambdaInvoke(this, 'Process Payment', {
lambdaFunction: processPayment,
outputPath: '$.Payload',
});
const inventoryTask = new tasks.LambdaInvoke(this, 'Reserve Inventory', {
lambdaFunction: reserveInventory,
outputPath: '$.Payload',
resultPath: '$.inventoryResult',
});
const notifyTask = new tasks.LambdaInvoke(this, 'Notify Customer', {
lambdaFunction: notifyCustomer,
outputPath: '$.Payload',
});
const refundTask = new tasks.LambdaInvoke(this, 'Refund Payment', {
lambdaFunction: refundPayment,
outputPath: '$.Payload',
});
// Success and failure states
const orderSuccess = new sfn.Succeed(this, 'Order Successful');
const orderFailed = new sfn.Fail(this, 'Order Failed', {
cause: 'Order processing failed',
error: 'OrderProcessingError',
});
// Define workflow
const definition = validateTask
.next(
new sfn.Choice(this, 'Order Valid?')
.when(sfn.Condition.booleanEquals('$.valid', true), paymentTask)
.otherwise(orderFailed)
);
paymentTask.next(
new sfn.Choice(this, 'Payment Successful?')
.when(
sfn.Condition.stringEquals('$.paymentStatus', 'SUCCESS'),
inventoryTask
)
.otherwise(orderFailed)
);
inventoryTask.next(
new sfn.Choice(this, 'Inventory Reserved?')
.when(
sfn.Condition.booleanEquals('$.inventoryResult.Payload.reserved', true),
notifyTask.next(orderSuccess)
)
.otherwise(
refundTask.next(
new sfn.Fail(this, 'Insufficient Inventory', {
cause: 'Unable to reserve inventory',
error: 'InventoryError',
})
)
)
);
// Create State Machine
const stateMachine = new sfn.StateMachine(this, 'OrderWorkflow', {
stateMachineName: 'order-processing-workflow',
definition,
timeout: cdk.Duration.minutes(5),
tracingEnabled: true,
});
// Output
new cdk.CfnOutput(this, 'StateMachineArn', {
value: stateMachine.stateMachineArn,
description: 'Order Workflow State Machine ARN',
});
}
}
Monitoring and Observability
CloudWatch Metrics and Alarms
// lib/monitoring-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as actions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
export class MonitoringStack extends cdk.Stack {
constructor(
scope: Construct,
id: string,
apiFunction: lambda.Function,
props?: cdk.StackProps
) {
super(scope, id, props);
// SNS topic for alarms
const alarmTopic = new sns.Topic(this, 'AlarmTopic', {
displayName: 'Lambda Alarms',
});
alarmTopic.addSubscription(
new subscriptions.EmailSubscription('ops-team@example.com')
);
// Error rate alarm
const errorAlarm = new cloudwatch.Alarm(this, 'ErrorAlarm', {
alarmName: 'Lambda-High-Error-Rate',
metric: apiFunction.metricErrors({
statistic: 'Sum',
period: cdk.Duration.minutes(5),
}),
threshold: 10,
evaluationPeriods: 2,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
errorAlarm.addAlarmAction(new actions.SnsAction(alarmTopic));
// Duration alarm (slow responses)
const durationAlarm = new cloudwatch.Alarm(this, 'DurationAlarm', {
alarmName: 'Lambda-Slow-Response',
metric: apiFunction.metricDuration({
statistic: 'Average',
period: cdk.Duration.minutes(5),
}),
threshold: 3000, // 3 seconds
evaluationPeriods: 2,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
});
durationAlarm.addAlarmAction(new actions.SnsAction(alarmTopic));
// Throttle alarm
const throttleAlarm = new cloudwatch.Alarm(this, 'ThrottleAlarm', {
alarmName: 'Lambda-Throttling',
metric: apiFunction.metricThrottles({
statistic: 'Sum',
period: cdk.Duration.minutes(5),
}),
threshold: 1,
evaluationPeriods: 1,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
});
throttleAlarm.addAlarmAction(new actions.SnsAction(alarmTopic));
// Dashboard
const dashboard = new cloudwatch.Dashboard(this, 'LambdaDashboard', {
dashboardName: 'Serverless-API-Dashboard',
});
dashboard.addWidgets(
new cloudwatch.GraphWidget({
title: 'Invocations',
left: [apiFunction.metricInvocations()],
}),
new cloudwatch.GraphWidget({
title: 'Errors',
left: [apiFunction.metricErrors()],
}),
new cloudwatch.GraphWidget({
title: 'Duration',
left: [apiFunction.metricDuration()],
}),
new cloudwatch.GraphWidget({
title: 'Throttles',
left: [apiFunction.metricThrottles()],
})
);
}
}
Best Practices Summary
1. Cost Optimization
- Right-size Lambda memory based on actual usage
- Use Compute Savings Plans for predictable workloads
- Implement caching (API Gateway, CloudFront)
- Set appropriate timeouts to avoid unnecessary charges
- Use ARM-based Graviton2 processors (20% cheaper)
2. Security
- Follow least privilege principle for IAM roles
- Use Secrets Manager for sensitive data
- Enable encryption at rest and in transit
- Implement API Gateway authentication (Cognito, Lambda authorizers)
- Regular security audits with AWS Security Hub
3. Performance
- Minimize cold starts (package size, runtime choice)
- Use provisioned concurrency for latency-sensitive functions
- Implement connection pooling for databases
- Enable X-Ray tracing for debugging
- Use Lambda layers for shared dependencies
4. Reliability
- Implement proper error handling and retries
- Use Dead Letter Queues (DLQs)
- Set up CloudWatch alarms
- Enable X-Ray for distributed tracing
- Test for failures (Chaos Engineering)
5. Operational Excellence
- Use Infrastructure as Code (CDK/Terraform)
- Implement CI/CD pipelines
- Tag all resources for cost allocation
- Document architecture and runbooks
- Monitor and log everything
Conclusion
Serverless architecture with AWS Lambda and CDK enables you to build scalable, cost-effective applications without managing infrastructure. By following the patterns and best practices outlined in this guide, you can create production-ready serverless applications that are secure, performant, and maintainable.
Key Takeaways:
- Leverage AWS Lambda for event-driven and API workloads
- Use AWS CDK for Infrastructure as Code
- Implement proper error handling and retries
- Monitor and optimize continuously
- Follow AWS Well-Architected Framework principles
Ready to build your serverless application? Forrict can help you design and implement serverless solutions tailored to your business needs.
Resources
- AWS Lambda Documentation
- AWS CDK Documentation
- Serverless Land
- AWS Well-Architected Serverless Lens
- Lambda Power Tuning
Forrict Team
AWS expert and consultant at Forrict, specializing in cloud architecture and AWS best practices for Dutch businesses.