Building NestJS Serverless Applications with AWS DynamoDB

Master the art of creating production-ready serverless APIs using NestJS, AWS Lambda, and DynamoDB. A comprehensive guide to cloud-native architecture.

Serverless architectures have revolutionized how developers build and deploy applications, offering automatic scaling, pay-per-use pricing, and reduced operational overhead. When combined with NestJS's structured approach to backend development and AWS DynamoDB's fully managed NoSQL database capabilities, developers can create robust, scalable applications that require minimal infrastructure management.

The intersection of NestJS's modular architecture and AWS's serverless ecosystem creates a powerful foundation for modern application development. NestJS provides a clear structure for organizing code, implementing dependency injection, and managing application lifecycle, while AWS serverless services eliminate the need for server provisioning and maintenance. DynamoDB complements this architecture by offering a highly available, durable database solution that scales automatically to handle virtually any workload without capacity planning or provisioning.

Why NestJS for Serverless

Key benefits that make NestJS ideal for serverless architectures

TypeScript Support

Full type safety and improved developer experience with TypeScript's static typing system

Dependency Injection

Clean separation of concerns with built-in DI system for testable, modular code

Modular Architecture

Organize code into modules that align naturally with microservices patterns

Decorator Patterns

Declarative code style that clearly expresses intent and reduces boilerplate

Setting Up Your Development Environment

Before diving into implementation, ensuring your development environment is properly configured for AWS serverless development is essential. This involves installing and configuring the AWS CLI, Serverless Framework, and NestJS CLI, along with setting up appropriate credentials for deploying to AWS. A well-configured environment streamlines the development workflow and prevents common deployment-related issues that can arise from misconfiguration.

Begin by installing the AWS CLI version 2, which provides command-line access to AWS services. Configure it with your AWS credentials using the aws configure command, specifying your access key ID, secret access key, default region, and output format. These credentials should have appropriate IAM permissions for deploying Lambda functions, creating DynamoDB tables, and managing related resources. For production deployments, consider using AWS IAM roles instead of long-lived credentials, and implement proper credential rotation practices.

The Serverless Framework serves as the deployment orchestrator for your serverless application, translating service configurations into CloudFormation or Terraform templates that provision AWS resources. Install it globally via npm using npm install -g serverless, then verify the installation by running serverless --version. The framework's dashboard feature provides additional capabilities for monitoring, testing, and managing deployments across multiple environments, though basic deployments can be accomplished without it.

# Install AWS CLI (if not already installed)
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /

# Configure AWS credentials
aws configure

# Install Serverless Framework
npm install -g serverless

# Install NestJS CLI
npm install -g @nestjs/cli

# Create new NestJS project
nest new my-serverless-api

# Navigate to project directory
cd my-serverless-api

NestJS CLI simplifies project creation and code generation. Install it using npm i -g @nestjs/cli, then create a new project with nest new project-name. This command generates a complete project structure with TypeScript configuration, testing setup, and a basic application module. For serverless projects, consider using the --strict flag during project creation to enable TypeScript's strict mode, which catches potential errors at compile time rather than runtime.

Installing Required Dependencies

The NestJS project requires several packages to integrate effectively with AWS services and support serverless deployment. Understanding these dependencies and their purposes helps in making informed decisions about which packages to include in your project. The core packages handle AWS SDK integration, while supplementary packages provide utility functions and type definitions that improve development experience and code quality.

The @aws-sdk/client-dynamodb package provides the low-level DynamoDB client from AWS SDK for JavaScript version 3. This client offers full access to all DynamoDB operations but requires manual handling of data type marshalling, where JavaScript objects must be converted to DynamoDB's attribute format and vice versa. For most applications, the document client abstraction provided by @aws-sdk/lib-dynamodb offers a more convenient interface that handles this marshalling automatically.

# Core AWS SDK packages
npm install @aws-sdk/client-dynamodb
npm install @aws-sdk/lib-dynamodb

# Serverless framework plugin for NestJS
npm install @nestjs/platform-express

# Serverless HTTP adapter for Lambda
npm install @vendia/serverless-express

# Utility packages
npm install aws-lambda

The @aws-sdk/lib-dynamodb package introduces the DynamoDBDocumentClient, which abstracts away the complexity of working with DynamoDB's native data types. When using the document client, you can pass regular JavaScript objects directly, and the client handles the conversion to and from DynamoDB's AttributeValue format. This abstraction significantly reduces boilerplate code and makes DynamoDB operations feel more natural in a TypeScript context.

Additionally, the @vendia/serverless-express package (formerly aws-serverless-express) provides the glue code necessary to run Express.js applications on AWS Lambda behind API Gateway. This adapter captures API Gateway events, converts them to Express-compatible requests, and routes them through your NestJS application. The adapter handles both synchronous request-response cycles and streaming responses, making it compatible with most Express middleware and NestJS features.

Understanding AWS SDK v3 Architecture

The AWS SDK for JavaScript version 3 represents a significant architectural improvement over version 2, introducing a modular design that reduces package size and improves tree-shaking capabilities. Understanding this architecture is crucial for writing efficient, maintainable code that takes advantage of SDK v3's features. The modular approach means you import only the clients and commands you need, rather than including the entire SDK in your application bundle.

The SDK v3 introduces the concept of clients, commands, and middleware as first-class citizens. Each AWS service has a corresponding client class that provides methods for all service operations. These methods return command objects that can be customized with input parameters before being sent to AWS. The middleware stack, positioned between the client and the network layer, allows for sophisticated request and response processing, including retry logic, logging, and telemetry collection.

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
 DynamoDBDocumentClient,
 GetCommand,
 PutCommand,
 UpdateCommand,
 DeleteCommand,
 QueryCommand,
 ScanCommand,
} from '@aws-sdk/lib-dynamodb';

// Initialize the base DynamoDB client
const client = new DynamoDBClient({
 region: process.env.AWS_REGION || 'us-east-1',
 credentials: {
 accessKeyId: process.env.AWS_ACCESS_KEY_ID,
 secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
 },
});

// Create the document client wrapper
const docClient = DynamoDBDocumentClient.from(client, {
 marshallOptions: {
 removeUndefinedValues: true,
 },
});

The DynamoDBClient constructor accepts a configuration object that controls the client's behavior. The region parameter specifies the AWS region for requests, while credentials provides authentication information. In serverless contexts, these values typically come from environment variables, which Lambda automatically populates from the configured execution role. This approach avoids hardcoding credentials and leverages IAM roles for authentication, following AWS security best practices.

The DynamoDBDocumentClient.from() method creates a document client from the base client, accepting optional configuration that controls marshalling behavior. The marshallOptions object, for example, can be used to remove undefined values from objects before sending them to DynamoDB, reducing storage costs and improving data cleanliness. Other options control whether to convert empty values to type defaults and how to handle special types like dates and buffers.

Designing Your DynamoDB Schema

Effective DynamoDB schema design requires a deep understanding of your application's access patterns and a willingness to denormalize data structures. Unlike relational databases, DynamoDB encourages duplicating data across tables rather than normalizing it, prioritizing read efficiency over storage efficiency. This denormalization approach eliminates the need for complex joins, which DynamoDB doesn't natively support, and ensures that queries can be satisfied by single table operations.

The primary key design determines how data is distributed across DynamoDB's partitioned storage. DynamoDB uses the partition key to distribute items across physical partitions, with each partition storing items that share the same partition key value. For tables with a simple primary key, the partition key uniquely identifies each item. For tables with a composite primary key (partition key plus sort key), the combination of both values uniquely identifies each item, and items with the same partition key are stored together in sorted order by their sort key.

// Example: E-commerce product catalog table
interface Product {
 productId: string; // Partition key
 category: string; // GSI partition key
 createdAt: number; // Sort key (timestamp)
 name: string;
 description: string;
 price: number;
 inventoryCount: number;
 tags: string[];
 lastUpdated: number;
}

// Example: User session table with TTL
interface Session {
 userId: string; // Partition key
 sessionId: string; // Sort key (UUID)
 createdAt: number;
 expiresAt: number; // TTL attribute
 token: string;
 preferences: Record<string, any>;
}

Global Secondary Indexes (GSIs) provide additional query flexibility by enabling different partition and sort key combinations. When designing GSIs, consider your application's query patterns and create indexes that support those patterns efficiently. Each GSI incurs additional storage costs and affects write throughput, so limiting the number of indexes to those strictly necessary for application queries helps control costs and maintain performance.

DynamoDB's Time to Live (TTL) feature automatically deletes items after a specified timestamp, making it ideal for implementing temporary data such as sessions, cached data, and ephemeral state. To use TTL, add a numeric attribute to your items containing the epoch timestamp at which the item should expire, then enable TTL on the table through the AWS Console or API. DynamoDB's background process handles asynchronous deletion of expired items without impacting table read or write performance.

For production applications, understanding how to optimize for entities becomes essential when designing schemas that support multiple entity types within a single table. This single-table design pattern is a common technique in serverless architectures that reduces costs and simplifies operations.

Creating DynamoDB Services in NestJS

Organizing DynamoDB operations within dedicated NestJS services promotes separation of concerns and makes code more maintainable. A typical pattern involves creating a service that handles all DynamoDB interactions, exposing methods for specific operations that controllers or other services can invoke. This service acts as an abstraction layer, encapsulating the details of DynamoDB communication and allowing the rest of the application to work with plain JavaScript objects.

The service class should be decorated with @Injectable() to make it available for dependency injection. By injecting the DynamoDBDocumentClient through the constructor, the service can be easily tested by mocking the client, and the client configuration can be centralized in the application module. This approach also facilitates potential client reuse across multiple services, though each service typically maintains its own client instance.

import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import {
 DynamoDBDocumentClient,
 PutCommand,
 GetCommand,
 UpdateCommand,
 DeleteCommand,
 QueryCommand,
 ScanCommand,
} from '@aws-sdk/lib-dynamodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';

@Injectable()
export class ProductsService implements OnModuleInit {
 private readonly logger = new Logger(ProductsService.name);
 private readonly tableName = 'Products';
 private docClient: DynamoDBDocumentClient;

 constructor() {
 const client = new DynamoDBClient({ region: 'us-east-1' });
 this.docClient = DynamoDBDocumentClient.from(client, {
 marshallOptions: {
 removeUndefinedValues: true,
 convertClassInstanceToMap: true,
 },
 });
 }

 async onModuleInit() {
 this.logger.log(`ProductsService initialized with table: ${this.tableName}`);
 }

 async createProduct(product: Omit<Product, 'createdAt' | 'lastUpdated'>): Promise<Product> {
 const now = Date.now();
 const item = {
 ...product,
 createdAt: now,
 lastUpdated: now,
 };

 await this.docClient.send(
 new PutCommand({
 TableName: this.tableName,
 Item: item,
 }),
 );

 return item;
 }

 async getProduct(productId: string): Promise<Product | null> {
 const result = await this.docClient.send(
 new GetCommand({
 TableName: this.tableName,
 Key: { productId },
 }),
 );

 return result.Item as Product || null;
 }

 async updateProduct(
 productId: string,
 updates: Partial<Omit<Product, 'productId' | 'createdAt'>>,
 ): Promise<Product | null> {
 const updateExpression = [];
 const expressionAttributeNames: Record<string, string> = {};
 const expressionAttributeValues: Record<string, any> = {};

 Object.entries(updates).forEach(([key, value], index) => {
 updateExpression.push(`#${key} = :val${index}`);
 expressionAttributeNames[`#${key}`] = key;
 expressionAttributeValues[`:val${index}`] = value;
 });

 updateExpression.push('#lastUpdated = :lastUpdated');
 expressionAttributeNames['#lastUpdated'] = 'lastUpdated';
 expressionAttributeValues[':lastUpdated'] = Date.now();

 const result = await this.docClient.send(
 new UpdateCommand({
 TableName: this.tableName,
 Key: { productId },
 UpdateExpression: `SET ${updateExpression.join(', ')}`,
 ExpressionAttributeNames: expressionAttributeNames,
 ExpressionAttributeValues: expressionAttributeValues,
 ReturnValues: 'ALL_NEW',
 }),
 );

 return result.Attributes as Product || null;
 }

 async deleteProduct(productId: string): Promise<boolean> {
 await this.docClient.send(
 new DeleteCommand({
 TableName: this.tableName,
 Key: { productId },
 }),
 );

 return true;
 }
}

The updateProduct method demonstrates the use of DynamoDB's UpdateExpression syntax, which provides a powerful way to modify existing items. The expression uses placeholders like #field for attribute names (necessary when field names conflict with DynamoDB reserved words) and :value for attribute values. The ReturnValues: 'ALL_NEW' option causes DynamoDB to return the entire updated item, allowing the caller to receive the latest state without making a separate read request.

Implementing CRUD Operations

Creating, reading, updating, and deleting items forms the foundation of any application's data layer. While DynamoDB's API differs from relational databases, the fundamental concepts remain similar. Understanding how to translate these CRUD operations into DynamoDB commands, while respecting its data model and performance characteristics, enables building efficient, scalable data layers for serverless applications.

The PutCommand creates new items or replaces existing items with the same key. Unlike traditional databases where insert operations might fail on duplicate keys, DynamoDB's PutCommand silently replaces existing items. For conditional inserts that should fail if the item already exists, use the ConditionExpression parameter with an attribute not exists check. This pattern proves useful for implementing optimistic locking or preventing duplicate key creation.

// Advanced CRUD operations with query patterns

async getProductsByCategory(category: string, limit = 20): Promise<Product[]> {
 const result = await this.docClient.send(
 new QueryCommand({
 TableName: this.tableName,
 IndexName: 'category-index', // GSI name
 KeyConditionExpression: '#category = :category',
 ExpressionAttributeNames: {
 '#category': 'category',
 },
 ExpressionAttributeValues: {
 ':category': category,
 },
 Limit: limit,
 ConsistentRead: false, // Use eventual consistency for better performance
 }),
 );

 return (result.Items as Product[]) || [];
}

async getRecentProducts(limit = 10): Promise<Product[]> {
 const result = await this.docClient.send(
 new ScanCommand({
 TableName: this.tableName,
 Limit: limit,
 TotalSegments: 1, // For parallel scans in production
 Segment: 0,
 }),
 );

 return (result.Items as Product[]) || [];
}

async updateProductWithCondition(
 productId: string,
 expectedVersion: number,
 updates: Partial<Product>,
): Promise<Product | null> {
 try {
 const result = await this.docClient.send(
 new UpdateCommand({
 TableName: this.tableName,
 Key: { productId },
 UpdateExpression: 'SET #updates = :updates, #version = :version, #lastUpdated = :now',
 ConditionExpression: '#version = :expectedVersion',
 ExpressionAttributeNames: {
 '#updates': 'updates',
 '#version': 'version',
 '#lastUpdated': 'lastUpdated',
 },
 ExpressionAttributeValues: {
 ':updates': updates,
 ':version': expectedVersion + 1,
 ':expectedVersion': expectedVersion,
 ':now': Date.now(),
 },
 ReturnValues: 'ALL_NEW',
 }),
 );

 return result.Attributes as Product;
 } catch (error) {
 if (error.name === 'ConditionalCheckFailedException') {
 this.logger.warn(`Concurrent modification detected for product: ${productId}`);
 return null;
 }
 throw error;
 }
}

The Query operation provides efficient, sorted access to items sharing a partition key. When combined with GSIs, this capability enables various access patterns that would require complex joins in relational databases. The QueryCommand accepts parameters controlling the sort direction (Ascending/Descending), pagination (Limit, ExclusiveStartKey), and consistency requirements (ConsistentRead).

For operations that don't align with partition key access patterns, the Scan operation reads through the entire table, applying filter expressions to identify matching items. While Scan operations can satisfy any query pattern, they consume read capacity proportional to the entire table size rather than the result set size. In production systems with significant data volumes, avoid Scan operations in favor of queries against appropriately designed GSIs.

Configuring the Serverless Framework

The Serverless Framework simplifies deploying and managing serverless applications by providing a declarative configuration format that describes the desired infrastructure state. The serverless.yml file specifies Lambda functions, API Gateway configurations, DynamoDB tables, IAM permissions, and deployment settings. Understanding how to configure these elements correctly ensures reliable, maintainable deployments that follow AWS and Serverless Framework best practices.

The IAM configuration defines the permissions that Lambda functions receive through their execution role. The iam.role.statements section specifies which AWS actions are permitted and on which resources. Following the principle of least privilege, grant only the permissions necessary for the function's operations. The !GetAtt and !Join intrinsic functions create references to dynamically provisioned resources, such as the DynamoDB table ARN, ensuring correct permissions regardless of deployment.

# serverless.yml
service: nestjs-dynamodb-api

frameworkVersion: '3'

provider:
 name: aws
 runtime: nodejs20.x
 stage: ${opt:stage, 'dev'}
 region: ${opt:region, 'us-east-1'}

 lambdaHashingVersion: 20201221

 environment:
 DYNAMODB_TABLE: ${self:service}-${self:provider.stage}
 AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1'

 iam:
 role:
 statements:
 - Effect: Allow
 Action:
 - dynamodb:Query
 - dynamodb:Scan
 - dynamodb:GetItem
 - dynamodb:PutItem
 - dynamodb:UpdateItem
 - dynamodb:DeleteItem
 Resource:
 - !GetAtt ProductsTable.Arn
 - !Join ['/', [!GetAtt ProductsTable.Arn, 'index/*']]
 - Effect: Allow
 Action:
 - dynamodb:DescribeTable
 Resource:
 - !GetAtt ProductsTable.Arn

functions:
 api:
 handler: dist/lambda.handler
 events:
 - http:
 path: /{proxy+}
 method: ANY
 - http:
 path: /
 method: ANY
 environment:
 DYNAMODB_TABLE: ${self:provider.environment.DYNAMODB_TABLE}
 memorySize: 256
 timeout: 30
 provisionedConcurrency: ${self:provider.stage == 'prod' ? 10 : 0}
 concurrency: ${self:provider.stage == 'prod' ? 100 : 10}

resources:
 Resources:
 ProductsTable:
 Type: AWS::DynamoDB::Table
 Properties:
 TableName: ${self:provider.environment.DYNAMODB_TABLE}
 BillingMode: PAY_PER_REQUEST
 AttributeDefinitions:
 - AttributeName: productId
 AttributeType: S
 - AttributeName: category
 AttributeType: S
 KeySchema:
 - AttributeName: productId
 KeyType: HASH
 GlobalSecondaryIndexes:
 - IndexName: category-index
 KeySchema:
 - AttributeName: category
 KeyType: HASH
 - AttributeName: createdAt
 KeyType: RANGE
 Projection:
 ProjectionType: ALL
 StreamSpecification:
 StreamViewType: NEW_AND_OLD_IMAGES
 Tags:
 - Key: Service
 Value: ${self:service}
 - Key: Stage
 Value: ${self:provider.stage}

plugins:
 - serverless-offline
 - serverless-webpack

custom:
 webpack:
 webpackConfig: webpack.config.js
 includeModules:
 forceExclude:
 - aws-sdk
 serverless-offline:
 httpPort: 3000
 lambdaPort: 3002

For developers exploring alternative deployment strategies, understanding how Cloudflare Workers compare with AWS Lambda can help inform platform decisions. Similarly, Zappa and AWS Lambda for serverless Django demonstrates how these patterns extend beyond Node.js ecosystems.

Creating the Lambda Handler

The Lambda handler serves as the entry point for serverless function invocations, translating API Gateway events into application requests and ensuring responses conform to the expected format. In NestJS applications, this handler typically initializes the NestJS application context and delegates request processing to the configured adapter. Proper handler implementation ensures cold starts are minimized and error handling is consistent across invocation types.

// src/lambda.ts
import { Handler, Context, APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import express from 'express';
import { AppModule } from './app.module';
import { Express } from 'express';
import * as awsServerlessExpress from 'aws-serverless-express';

let cachedServer: awsServerlessExpress.Server;

async function bootstrapServer(): Promise<awsServerlessExpress.Server> {
 if (!cachedServer) {
 const expressApp = express();
 const adapter = new ExpressAdapter(expressApp);

 const app = await NestFactory.create(AppModule, adapter);

 app.enableCors({
 origin: '*',
 methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
 credentials: true,
 });

 app.setGlobalPrefix('api');

 await app.init();
 cachedServer = awsServerlessExpress.createServer(expressApp);
 }
 return cachedServer;
}

export const handler: Handler<APIGatewayProxyEvent, APIGatewayProxyResult> = async (
 event: APIGatewayProxyEvent,
 context: Context,
): Promise<APIGatewayProxyResult> => {
 context.callbackWaitsForEmptyEventLoop = false;

 const server = await bootstrapServer();

 return awsServerlessExpress.proxy(server, event, {
 binary: true,
 request: {
 headers: event.headers,
 method: event.httpMethod,
 path: event.path,
 query: event.queryStringParameters || {},
 },
 response: {
 statusCode: 200,
 headers: {
 'Content-Type': 'application/json',
 'Access-Control-Allow-Origin': '*',
 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
 },
 },
 });
};

The handler implementation leverages NestJS's ability to create application instances from Express adapters. The cachedServer variable ensures that the Express server is initialized only once and reused across invocations, minimizing cold start impact. Setting context.callbackWaitsForEmptyEventLoop = false prevents Lambda from waiting for asynchronous operations to complete before returning, which is important when using database connections or other resources that might complete after the response is sent.

The aws-serverless-express library handles the translation between API Gateway's event format and Express's request/response objects. The proxy function accepts customization options for request and response handling, allowing modification of headers, status codes, and other response properties. For production deployments, consider adding response compression and optimizing the middleware stack to reduce invocation latency.

Building REST API Controllers

NestJS controllers handle incoming HTTP requests and coordinate responses, acting as the presentation layer for your serverless API. Controllers define routes through decorators, extract parameters from requests, invoke services for business logic, and format responses. The structured approach that NestJS provides makes it straightforward to build APIs that are both maintainable and aligned with RESTful principles.

import {
 Controller,
 Get,
 Post,
 Put,
 Delete,
 Body,
 Param,
 HttpCode,
 HttpStatus,
 NotFoundException,
 BadRequestException,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { Product } from './product.interface';

@Controller('products')
export class ProductsController {
 constructor(private readonly productsService: ProductsService) {}

 @Post()
 @HttpCode(HttpStatus.CREATED)
 async createProduct(
 @Body() createProductDto: Omit<Product, 'productId' | 'createdAt' | 'lastUpdated'>,
 ): Promise<Product> {
 const productId = this.generateId();
 return this.productsService.createProduct({
 productId,
 ...createProductDto,
 });
 }

 @Get()
 async listProducts(
 @Param('category') category?: string,
 ): Promise<{ products: Product[]; count: number }> {
 if (category) {
 const products = await this.productsService.getProductsByCategory(category);
 return { products, count: products.length };
 }

 const products = await this.productsService.getRecentProducts(20);
 return { products, count: products.length };
 }

 @Get(':id')
 async getProduct(@Param('id') id: string): Promise<Product> {
 const product = await this.productsService.getProduct(id);
 if (!product) {
 throw new NotFoundException(`Product with ID ${id} not found`);
 }
 return product;
 }

 @Put(':id')
 async updateProduct(
 @Param('id') id: string,
 @Body() updateProductDto: Partial<Product>,
 ): Promise<Product> {
 const product = await this.productsService.getProduct(id);
 if (!product) {
 throw new NotFoundException(`Product with ID ${id} not found`);
 }

 const { productId, createdAt, ...safeUpdates } = updateProductDto;
 const updated = await this.productsService.updateProduct(id, safeUpdates);

 if (!updated) {
 throw new BadRequestException('Failed to update product');
 }

 return updated;
 }

 @Delete(':id')
 @HttpCode(HttpStatus.NO_CONTENT)
 async deleteProduct(@Param('id') id: string): Promise<void> {
 const product = await this.productsService.getProduct(id);
 if (!product) {
 throw new NotFoundException(`Product with ID ${id} not found`);
 }

 await this.productsService.deleteProduct(id);
 }

 private generateId(): string {
 return `prod_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
 }
}

The controller uses dependency injection to receive the ProductsService instance, keeping the controller focused on HTTP handling while delegating business logic to services. Parameter decorators like @Body(), @Param(), and @Query() extract values from different parts of the request, while HTTP method decorators (@Get(), @Post(), etc.) define the route handlers. The @SetGlobalPrefix('api') decorator (applied in the Lambda handler) ensures all routes are prefixed consistently.

Error handling in NestJS uses exception filters, which can be configured globally or per-controller. The NotFoundException and BadRequestException are built-in HTTP exceptions that NestJS automatically translates into appropriate HTTP responses. For custom error handling, implement the ExceptionFilter interface to catch and format errors consistently across your API.

Best Practices for Serverless DynamoDB

Building production-ready serverless applications with DynamoDB requires adherence to several best practices that ensure reliability, performance, and cost-efficiency. These practices address common challenges in serverless architectures, including cold start optimization, connection management, error handling, and cost control. Understanding and implementing these patterns separates production-grade applications from prototypes.

Connection management proves critical in serverless contexts because Lambda functions can be created and destroyed frequently. Database connections should be established once per Lambda execution and reused across invocations through module-level caching or singleton patterns. The DynamoDB client handles connection pooling internally, but initializing clients outside the handler ensures connections persist between invocations within the same container.

// Best practice: Client reuse pattern
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({
 region: process.env.AWS_REGION,
 maxAttempts: 3,
 retryMode: 'adaptive',
});

const docClient = DynamoDBDocumentClient.from(client, {
 marshallOptions: {
 removeUndefinedValues: true,
 },
});

export { docClient };

// Best practice: Circuit breaker for resilience
import { CircuitBreaker, CircuitBreakerState } from './circuit-breaker';

export class DynamoDBService {
 private readonly circuitBreaker: CircuitBreaker;

 constructor() {
 this.circuitBreaker = new CircuitBreaker({
 failureThreshold: 5,
 resetTimeout: 30000,
 halfOpenRequests: 3,
 });
 }

 async safeQuery(command: any): Promise<any> {
 return this.circuitBreaker.execute(async () => {
 return docClient.send(command);
 });
 }
}

Implementing circuit breaker patterns protects your application from cascade failures when DynamoDB experiences issues. The circuit breaker tracks failure rates and opens (stops making requests) after a threshold is exceeded, allowing downstream services time to recover. After a reset timeout, the circuit breaker enters half-open state, allowing a limited number of requests through to test if the service has recovered.

Cost optimization in DynamoDB involves right-sizing read and write capacity, using appropriate consistency models, and leveraging features like TTL and on-demand capacity. On-demand capacity mode automatically scales throughput based on workload, simplifying capacity management for applications with variable traffic patterns. For predictable workloads, provisioned capacity with auto-scaling can provide cost savings while maintaining performance guarantees.

Error Handling and Retry Strategies

Robust error handling distinguishes production-ready applications from those that fail under adverse conditions. DynamoDB operations can fail for various reasons, including network issues, throttling, capacity exceeded, and conditional check failures. Implementing comprehensive error handling that distinguishes between retryable and non-retryable errors ensures your application degrades gracefully and recovers automatically when issues resolve.

The AWS SDK v3 implements intelligent retry logic through its built-in retry mechanism, which handles transient errors like throttling exceptions and network failures automatically. The maxAttempts and retryMode configuration options control retry behavior, with 'standard' mode providing exponential backoff and 'adaptive' mode offering additional congestion control. Understanding these configurations helps tune retry behavior for specific application requirements.

// Comprehensive error handling strategy
import { DynamoDBServiceException } from '@aws-sdk/client-dynamodb';

interface DynamoDBError extends Error {
 name: string;
 $fault?: 'client' | 'server';
 $metadata?: {
 httpStatusCode: number;
 requestId?: string;
 extendedRequestId?: string;
 cfId?: string;
 attempts?: number;
 totalRetryDelay?: number;
 };
}

export class DynamoDBErrorHandler {
 static isRetryable(error: DynamoDBError): boolean {
 // Throttling exceptions are retryable
 if (error.name === 'ThrottlingException' || error.name === 'ThrottledException') {
 return true;
 }

 // Provisioned throughput exceptions may be retryable
 if (error.name === 'ProvisionedThroughputExceededException') {
 return true;
 }

 // Network errors are retryable
 if (error.name === 'NetworkingError' || error.name === 'TimeoutError') {
 return true;
 }

 // Conditional check failures are NOT retryable
 if (error.name === 'ConditionalCheckFailedException') {
 return false;
 }

 // Resource not found errors are NOT retryable
 if (error.name === 'ResourceNotFoundException') {
 return false;
 }

 // Client faults (4xx) are generally not retryable
 if (error.$fault === 'client') {
 return false;
 }

 // Server faults (5xx) are retryable
 if (error.$fault === 'server') {
 return true;
 }

 return false;
 }

 static handleError(error: DynamoDBError): never {
 const logger = console;

 logger.error('DynamoDB operation failed', {
 errorName: error.name,
 message: error.message,
 fault: error.$fault,
 statusCode: error.$metadata?.httpStatusCode,
 requestId: error.$metadata?.requestId,
 attempts: error.$metadata?.attempts,
 totalRetryDelay: error.$metadata?.totalRetryDelay,
 });

 if (error.name === 'ResourceNotFoundException') {
 throw new NotFoundException('Requested resource not found');
 }

 if (error.name === 'ConditionalCheckFailedException') {
 throw new ConflictException('Concurrent modification detected');
 }

 if (error.name === 'ThrottlingException') {
 throw new ServiceUnavailableException('Service temporarily unavailable');
 }

 throw error;
 }
}

Logging error details, including request IDs and retry attempts, facilitates debugging and monitoring. The AWS SDK includes extensive metadata in error responses, including HTTP status codes, request IDs, and retry attempt counts. Capturing this information in structured logs enables correlation with AWS CloudTrail records and faster root cause analysis during incidents.

Local Development and Testing

Developing serverless applications locally requires tools that simulate AWS services and provide rapid feedback during development. The Serverless Framework's offline plugin, combined with local DynamoDB through Docker or AWS SAM CLI, enables a complete local development environment without incurring AWS costs or requiring network connectivity for every change.

The Serverless Offline plugin intercepts deployment commands and launches local HTTP servers that simulate API Gateway and Lambda. Configure the plugin in serverless.yml to specify custom ports, CORS settings, and Lambda timeouts that differ from production configurations. When running serverless offline, the plugin watches for file changes and automatically reloads functions, providing rapid feedback during development.

# Local development configuration
custom:
 serverless-offline:
 httpPort: 3000
 lambdaPort: 3002
 noPrependStageInUrl: false
 noAppendStackInResource: true
 resourceRoutes: true
 disableCookieValidation: true
 corsConfig:
 origin: '*'
 headers:
 - Content-Type
 - Authorization
 credentials: true
 dynamodb:
 stages:
 - dev
 start:
 port: 8000
 inMemory: true
 heapInitial: 200m
 heapMax: 1g
 migrate: true
 seed: true

plugins:
 - serverless-offline
 - serverless-dynamodb-local

Testing serverless applications requires strategies that account for the unique characteristics of Lambda execution and DynamoDB interactions. Unit tests should mock AWS SDK calls and focus on business logic, while integration tests verify the complete request-response flow. End-to-end tests using local DynamoDB instances ensure that database operations behave as expected before deploying to AWS.

For developers evaluating different serverless platforms, comparing Netlify vs Cloudflare Pages and Firebase vs Netlify provides valuable context for platform selection. The choice of deployment platform significantly impacts development workflow, performance characteristics, and cost structure.

Deploying to AWS

Deploying serverless applications involves packaging code, provisioning infrastructure, and configuring connections between services. The Serverless Framework handles these tasks through its deployment commands, which package the application, upload to S3, and create or update CloudFormation stacks. Understanding the deployment process helps troubleshoot issues and optimize deployment workflows.

# Install dependencies
npm install

# Build TypeScript
npm run build

# Deploy to development
serverless deploy --stage dev --region us-east-1

# Deploy to production
serverless deploy --stage prod --region us-east-1

# Deploy function only (faster updates)
serverless deploy function --function api

# View logs
serverless logs -f api --stage dev --tail

# Remove deployment
serverless remove --stage dev

The first deployment provisions all resources defined in the CloudFormation template, which can take several minutes. Subsequent deployments are faster because only changed resources require updates. The Serverless Framework tracks deployment state and determines which resources require modification, avoiding unnecessary operations that would cause service interruptions.

Continuous deployment pipelines can automate deployments triggered by code changes. Integrating with AWS CodePipeline, GitHub Actions, or similar services enables automatic deployments when code is merged to protected branches. These pipelines typically include testing stages that must pass before deployment proceeds, ensuring that only quality code reaches production environments.

For teams adopting infrastructure-as-code practices, exploring the Introduction to AWS Cloud Development Kit provides insights into alternative infrastructure provisioning approaches that complement the Serverless Framework.

Monitoring and Performance Optimization

Production serverless applications require comprehensive monitoring to detect issues, optimize performance, and understand usage patterns. AWS provides native monitoring through CloudWatch, including metrics, logs, and alarms. Additional tools like AWS X-Ray provide distributed tracing capabilities that help identify performance bottlenecks across service boundaries.

CloudWatch automatically collects metrics for Lambda functions, including invocation counts, duration, errors, and throttles. These metrics can be visualized in CloudWatch Dashboards and used to create alarms that notify operations teams when error rates exceed thresholds or duration increases significantly. Custom metrics can be emitted from application code to track business-level indicators alongside infrastructure metrics.

// Custom metrics example
import { MetricsLogger, Unit } from 'aws-sdk-client-cloudwatch-metrics';

const metrics = new MetricsLogger();

export function recordProductCreated(category: string) {
 metrics.putMetric('ProductCreated', 1, Unit.Count);
 metrics.putDimension('Category', category);
 metrics.setProperty('Service', 'ProductsAPI');
 metrics.setProperty('Environment', process.env.NODE_ENV || 'development');
}

export function recordQueryDuration(duration: number, indexType: string) {
 metrics.putMetric('QueryDuration', duration, Unit.Milliseconds);
 metrics.putDimension('IndexType', indexType);
}

// Flush metrics at the end of Lambda invocation
metrics.flush();

Cold start performance significantly impacts user experience, particularly for latency-sensitive applications. Strategies for minimizing cold start include provisioned concurrency (keeping functions warm), optimizing package size (reducing initialization time), and using connection keep-alive (avoiding reconnection overhead). Measuring cold start duration through distributed tracing helps quantify improvements from optimization efforts.

For video content delivery use cases, understanding streaming optimized videos from AWS S3 demonstrates how serverless architectures can be combined with media processing workflows. This complements the API-focused patterns covered in this guide.

Frequently Asked Questions

Conclusion

Building NestJS serverless applications with AWS DynamoDB combines the best of modern backend frameworks with cloud-native infrastructure patterns. NestJS's structured approach, TypeScript support, and dependency injection system provide a solid foundation for maintainable serverless code, while DynamoDB's fully managed, scalable nature eliminates database operational concerns. The patterns and practices explored in this guide--from project setup through production monitoring--provide a comprehensive foundation for building production-grade serverless applications.

The serverless paradigm continues to evolve, with new capabilities and services regularly emerging from AWS. Staying current with these developments while maintaining solid fundamentals ensures your applications leverage the latest improvements while remaining stable and maintainable. The investment in understanding these core patterns pays dividends through faster development velocity, reduced operational overhead, and more resilient applications.

For organizations looking to modernize their cloud infrastructure services, NestJS and DynamoDB provide a powerful combination that scales automatically with demand. Our team of cloud architecture experts can help you design and implement serverless solutions tailored to your specific business requirements, ensuring you get the most out of these technologies.

Ready to Build Your Serverless Application?

Our team of cloud architecture experts can help you design and implement scalable serverless solutions tailored to your business needs.