Serverless Architecture with AWS Lambda and CDK | Forrict Skip to main content
Serverless AWS Development

Serverless Architecture with AWS Lambda and CDK

Forrict Team
Serverless Architecture with AWS Lambda and CDK
Build production-ready serverless applications on AWS using Lambda, API Gateway, EventBridge, and Step Functions with TypeScript CDK patterns and best practices

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

F

Forrict Team

AWS expert and consultant at Forrict, specializing in cloud architecture and AWS best practices for Dutch businesses.

Tags

AWS Lambda Serverless API Gateway EventBridge Step Functions CDK TypeScript Cloud Architecture

Related Articles

Ready to Transform Your AWS Infrastructure?

Let's discuss how we can help optimize your cloud journey