Skip to content
Ayhan Sipahi Ayhan Sipahi

Zod + OpenAPI + AWS Lambda: Schema-First API Development with CDK

Keep a Lambda API and its OpenAPI contract in sync by generating the spec from Zod schemas and wiring that generation into CDK deployment.

When a Lambda API and its OpenAPI spec are maintained separately, schema drift is inevitable. A field added to Zod validation quietly goes missing from the contract clients generate code against, and downstream consumers break without warning. The fix is to keep one source of truth: wire Zod schemas directly into OpenAPI spec generation and CDK deployment, so the published contract cannot diverge from the validation that runs on every request.

Documentation Drift: Business Cost

The classic serverless API development problem is four different sources of truth diverging silently:

// 1. TypeScript interfaces (what developers think the API does)
interface CreateUserRequest {
  email: string;
  username: string;
  age?: number;
  // I added this field...
  company?: string;
}

// 2. OpenAPI spec (what clients generate code from)
const openApiSpec = {
  paths: {
    '/users': {
      post: {
        requestBody: {
          // But forgot to update this
          schema: {
            type: 'object',
            properties: {
              email: { type: 'string' },
              username: { type: 'string' },
              age: { type: 'number' }
              // Missing: company field
            }
          }
        }
      }
    }
  }
};

// 3. Lambda validation (inconsistent and incomplete)
if (!event.body.email || typeof event.body.email !== 'string') {
  throw new Error('Invalid email');
}
// No validation for company field

// 4. Database schema (yet another source of truth)
CREATE TABLE users (
  id UUID PRIMARY KEY,
  email VARCHAR(255) NOT NULL,
  username VARCHAR(50) NOT NULL,
  age INTEGER,
  company_name VARCHAR(100) -- Different field name!
);

Four definitions of the same resource, and nothing forces them to agree. Drift usually surfaces as an integration failure on the client side, which is the most expensive place to find it: the team that reports the bug is not the team that can fix it.

The Solution: Single Source of Truth

Working from the principle of Single Source of Truth, this approach uses Zod schemas as the definitive API contract, automatically generating:

  • Compile-time TypeScript types (no more interface drift)
  • OpenAPI 3.0 specifications (always in sync)
  • Runtime validation (catch bad data before it hits your database)
  • Structured error responses (clients know exactly what went wrong)
  • Database migrations (with custom tooling)

Here’s the architecture:

Zod Schema

TypeScript Types

OpenAPI Spec

Runtime Validation

Client SDK Generation

API Gateway

Lambda Handler

Type-Safe Response

Project Setup

Four packages carry the pattern: Zod for the schemas, the two OpenAPI adapters for spec generation, and Powertools for the observability the handler wrapper emits.

# Core dependencies
npm install zod @anatine/zod-openapi @asteasolutions/zod-to-openapi
npm install uuid @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install --save-dev @types/aws-lambda @types/uuid

# For production monitoring
npm install @middy/core @aws-lambda-powertools/logger @aws-lambda-powertools/tracer @aws-lambda-powertools/metrics

Core Schema Definitions

Error shape, response envelope, and handler configuration live in one module, so every route inherits the same contract:

// lib/api/types.ts - Core schema definitions
import { z } from 'zod';
import { extendZodWithOpenApi } from '@anatine/zod-openapi';
import {
  APIGatewayProxyEventV2,
  APIGatewayProxyResultV2,
  Context
} from 'aws-lambda';

// Extend Zod with OpenAPI capabilities
extendZodWithOpenApi(z);

// Error schema for structured error responses
export const ErrorResponseSchema = z.object({
  error: z.string().openapi({
    example: 'Validation failed',
    description: 'Human-readable error message'
  }),
  details: z.array(z.object({
    path: z.string().openapi({
      example: 'email',
      description: 'The field that failed validation'
    }),
    message: z.string().openapi({
      example: 'Invalid email format',
      description: 'Specific validation error'
    }),
    code: z.string().openapi({
      example: 'INVALID_EMAIL',
      description: 'Machine-readable error code'
    })
  })).optional(),
  requestId: z.string().uuid().openapi({
    example: '123e4567-e89b-12d3-a456-426614174000',
    description: 'Unique request identifier for debugging'
  }),
  timestamp: z.string().datetime().openapi({
    example: '2023-01-15T10:30:45.123Z',
    description: 'When the error occurred'
  })
}).openapi('ErrorResponse');

// Response wrapper that provides consistent client experience
export const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
  z.object({
    success: z.boolean().openapi({
      example: true,
      description: 'Whether the request succeeded'
    }),
    data: dataSchema.optional(),
    error: ErrorResponseSchema.optional(),
    metadata: z.object({
      timestamp: z.string().datetime().openapi({
        example: '2023-01-15T10:30:45.123Z'
      }),
      version: z.string().openapi({
        example: '1.2.3',
        description: 'API version that processed this request'
      }),
      requestId: z.string().uuid(),
      // Performance metrics for debugging
      executionTime: z.number().openapi({
        example: 245,
        description: 'Request processing time in milliseconds'
      })
    })
  });

// Handler configuration for type-safe API routes
export interface HandlerConfig<
  TBody extends z.ZodType,
  TQuery extends z.ZodType,
  TPath extends z.ZodType,
  TResponse extends z.ZodType
> {
  body?: TBody;
  query?: TQuery;
  path?: TPath;
  response: TResponse;
  headers?: z.ZodType;
  // Per-route policy, enforced by the wrapper
  auth?: {
    required: boolean;
    roles?: string[];
  };
  rateLimit?: {
    requestsPerMinute: number;
    burstLimit?: number;
  };
  // OpenAPI metadata
  openapi?: {
    summary: string;
    description: string;
    tags: string[];
    deprecated?: boolean;
  };
}

// Type inference that provides full IntelliSense
export type InferredHandler<T extends HandlerConfig<any, any, any, any>> = {
  body: T['body'] extends z.ZodType ? z.infer<T['body']> : never;
  query: T['query'] extends z.ZodType ? z.infer<T['query']> : never;
  path: T['path'] extends z.ZodType ? z.infer<T['path']> : never;
  response: z.infer<T['response']>;
};

The Handler Wrapper

The wrapper parses each request component against its schema, validates the response on the way out, and maps failures to status codes:

// lib/api/handler.ts - Type-safe request/response wrapper
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context } from 'aws-lambda';
import { z, ZodError } from 'zod';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from '@aws-lambda-powertools/logger';
import { Tracer } from '@aws-lambda-powertools/tracer';
import { Metrics } from '@aws-lambda-powertools/metrics';

const logger = new Logger();
const tracer = new Tracer();
const metrics = new Metrics();

export function createHandler<T extends HandlerConfig<any, any, any, any>>(
  config: T,
  handler: (
    event: {
      body: T['body'] extends z.ZodType ? z.infer<T['body']> : undefined;
      query: T['query'] extends z.ZodType ? z.infer<T['query']> : undefined;
      path: T['path'] extends z.ZodType ? z.infer<T['path']> : undefined;
      headers: T['headers'] extends z.ZodType ? z.infer<T['headers']> : Record<string, string>;
      raw: APIGatewayProxyEventV2;
      userId?: string;  // Set when config.auth.required is true
    },
    context: Context
  ) => Promise<z.infer<T['response']>>
): (event: APIGatewayProxyEventV2, context: Context) => Promise<APIGatewayProxyResultV2> {
  return async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
    const requestId = context.requestId || uuidv4();
    const startTime = Date.now();
    // Declared outside the try so the catch block can still log it
    let userId: string | undefined;

    // Add trace metadata
    tracer.putAnnotation('requestId', requestId);
    tracer.putAnnotation('httpMethod', event.requestContext.http.method);
    tracer.putAnnotation('path', event.requestContext.http.path);

    logger.info('Request received', {
      requestId,
      method: event.requestContext.http.method,
      path: event.requestContext.http.path,
      userAgent: event.headers['user-agent'],
      sourceIp: event.requestContext.http.sourceIp,
    });

    try {
      // Parse and validate request components with detailed error tracking
      let parsedBody: any;
      try {
        if (config.body && event.body) {
          const bodyJson = JSON.parse(event.body);
          parsedBody = config.body.parse(bodyJson);
          logger.debug('Body validation successful', { requestId });
        }
      } catch (error) {
        if (error instanceof SyntaxError) {
          metrics.addMetric('ValidationErrors', 'Count', 1);
          throw new Error('Invalid JSON in request body');
        }
        throw error;
      }

      const parsedQuery = config.query && event.queryStringParameters
        ? config.query.parse(event.queryStringParameters)
        : undefined;

      const parsedPath = config.path && event.pathParameters
        ? config.path.parse(event.pathParameters)
        : undefined;

      const parsedHeaders = config.headers && event.headers
        ? config.headers.parse(event.headers)
        : event.headers;

      // Auth validation
      if (config.auth?.required) {
        const authHeader = event.headers.authorization;
        if (!authHeader) {
          metrics.addMetric('AuthenticationErrors', 'Count', 1);
          throw new Error('Authorization header required');
        }
        // Extract user ID from JWT or other auth mechanism
        userId = await validateAuthToken(authHeader);
        tracer.putAnnotation('userId', userId);
      }

      // Execute handler with validated inputs
      const result = await handler({
        body: parsedBody,
        query: parsedQuery,
        path: parsedPath,
        headers: parsedHeaders,
        raw: event,
        userId
      }, context);

      // Validate response against schema
      const validatedResponse = config.response.parse(result);

      const executionTime = Date.now() - startTime;

      // Record success metrics
      metrics.addMetric('SuccessfulRequests', 'Count', 1);
      metrics.addMetric('ExecutionTime', 'Milliseconds', executionTime);

      logger.info('Request completed successfully', {
        requestId,
        executionTime,
        responseSize: JSON.stringify(validatedResponse).length
      });

      return {
        statusCode: 200,
        headers: {
          'Content-Type': 'application/json',
          'X-Request-Id': requestId,
          'X-API-Version': process.env.API_VERSION || '1.0.0',
          // Baseline security headers
          'X-Content-Type-Options': 'nosniff',
          'X-Frame-Options': 'DENY',
          'X-XSS-Protection': '1; mode=block'
        },
        body: JSON.stringify({
          success: true,
          data: validatedResponse,
          metadata: {
            timestamp: new Date().toISOString(),
            version: process.env.API_VERSION || '1.0.0',
            requestId,
            executionTime: Date.now() - startTime
          }
        })
      };
    } catch (error) {
      const executionTime = Date.now() - startTime;

      // Track error metrics
      metrics.addMetric('ErrorRequests', 'Count', 1);
      metrics.addMetric('ErrorExecutionTime', 'Milliseconds', executionTime);

      // Handle validation errors
      if (error instanceof ZodError) {
        logger.warn('Validation error', {
          requestId,
          errors: error.issues,
          path: event.requestContext.http.path,
          method: event.requestContext.http.method
        });

        metrics.addMetric('ValidationErrors', 'Count', 1);

        return {
          statusCode: 400,
          headers: {
            'Content-Type': 'application/json',
            'X-Request-Id': requestId,
            'X-API-Version': process.env.API_VERSION || '1.0.0'
          },
          body: JSON.stringify({
            success: false,
            error: {
              error: 'Validation failed',
              details: error.issues.map(e => ({
                path: e.path.join('.'),
                message: e.message,
                code: `INVALID_${e.path.join('_').toUpperCase()}`,
                received: e.input
              })),
              requestId,
              timestamp: new Date().toISOString()
            },
            metadata: {
              timestamp: new Date().toISOString(),
              version: process.env.API_VERSION || '1.0.0',
              requestId,
              executionTime
            }
          })
        };
      }

      // Handle authentication errors
      if (error.message === 'Authorization header required' ||
          error.message.includes('Invalid token')) {
        return {
          statusCode: 401,
          headers: {
            'Content-Type': 'application/json',
            'X-Request-Id': requestId
          },
          body: JSON.stringify({
            success: false,
            error: {
              error: 'Unauthorized',
              requestId,
              timestamp: new Date().toISOString()
            },
            metadata: {
              timestamp: new Date().toISOString(),
              version: process.env.API_VERSION || '1.0.0',
              requestId,
              executionTime
            }
          })
        };
      }

      // Handle other errors (with proper logging for debugging)
      logger.error('Handler error', {
        requestId,
        error: error.message,
        stack: error.stack,
        path: event.requestContext.http.path,
        method: event.requestContext.http.method,
        userId: userId || 'anonymous'
      });

      metrics.addMetric('InternalErrors', 'Count', 1);

      return {
        statusCode: 500,
        headers: {
          'Content-Type': 'application/json',
          'X-Request-Id': requestId,
          'X-API-Version': process.env.API_VERSION || '1.0.0'
        },
        body: JSON.stringify({
          success: false,
          error: {
            error: 'Internal server error',
            requestId,
            timestamp: new Date().toISOString(),
            // In development, show actual error
            ...(process.env.NODE_ENV === 'development' && {
              details: error.message,
              stack: error.stack
            })
          },
          metadata: {
            timestamp: new Date().toISOString(),
            version: process.env.API_VERSION || '1.0.0',
            requestId,
            executionTime
          }
        })
      };
    }
  };
}

// Helper function for auth validation
async function validateAuthToken(authHeader: string): Promise<string> {
  // Implementation depends on your auth provider
  // Could be JWT validation, Cognito, etc.
  const token = authHeader.replace('Bearer ', '');

  // This is where you'd validate the token
  // For example purposes, we'll just extract a fake user ID
  try {
    // Your token validation logic here
    return 'user-123';  // Return actual user ID
  } catch (error) {
    throw new Error('Invalid token');
  }
}

A Complete User Management API Example

Here’s a comprehensive user management API that demonstrates these patterns:

// lib/api/schemas/user.ts
import { z } from 'zod';
import { extendZodWithOpenApi } from '@anatine/zod-openapi';

extendZodWithOpenApi(z);

// Shared schemas
export const UserIdSchema = z.string().uuid().openapi({
  example: '123e4567-e89b-12d3-a456-426614174000'
});

export const EmailSchema = z.string().email().openapi({
  example: '[email protected]',
  description: 'Valid email address'
});

// User entity
export const UserSchema = z.object({
  id: UserIdSchema,
  email: EmailSchema,
  username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/).openapi({
    example: 'john_doe',
    description: 'Alphanumeric username with underscores and hyphens'
  }),
  fullName: z.string().min(1).max(100).openapi({
    example: 'John Doe'
  }),
  age: z.number().int().min(13).max(120).optional().openapi({
    example: 25,
    description: 'User age (13-120)'
  }),
  role: z.enum(['user', 'admin', 'moderator']).default('user').openapi({
    example: 'user'
  }),
  metadata: z.object({
    createdAt: z.string().datetime(),
    updatedAt: z.string().datetime(),
    lastLoginAt: z.string().datetime().optional()
  }),
  preferences: z.object({
    notifications: z.boolean().default(true),
    theme: z.enum(['light', 'dark', 'system']).default('system'),
    language: z.string().default('en')
  }).optional()
}).openapi('User');

// Request schemas
export const CreateUserRequestSchema = UserSchema
  .pick({ email: true, username: true, fullName: true, age: true })
  .openapi('CreateUserRequest');

export const UpdateUserRequestSchema = UserSchema
  .pick({ fullName: true, age: true, preferences: true })
  .partial()
  .openapi('UpdateUserRequest');

export const ListUsersQuerySchema = z.object({
  limit: z.string().regex(/^\d+$/).transform(Number).pipe(
    z.number().int().min(1).max(100)
  ).default('20').openapi({
    example: '20',
    description: 'Number of users to return (1-100)'
  }),
  offset: z.string().regex(/^\d+$/).transform(Number).pipe(
    z.number().int().min(0)
  ).default('0').openapi({
    example: '0'
  }),
  role: z.enum(['user', 'admin', 'moderator']).optional(),
  sortBy: z.enum(['createdAt', 'username', 'email']).default('createdAt'),
  sortOrder: z.enum(['asc', 'desc']).default('desc')
}).openapi('ListUsersQuery');

// Response schemas
export const UserResponseSchema = UserSchema.openapi('UserResponse');

export const UsersListResponseSchema = z.object({
  users: z.array(UserResponseSchema),
  pagination: z.object({
    total: z.number().int(),
    limit: z.number().int(),
    offset: z.number().int(),
    hasMore: z.boolean()
  })
}).openapi('UsersListResponse');

Lambda Handlers

Now let’s implement the Lambda functions using our type-safe wrapper:

// lambda/handlers/createUser.ts
import { createHandler } from '../../lib/api/handler';
import { CreateUserRequestSchema, UserResponseSchema } from '../../lib/api/schemas/user';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { v4 as uuidv4 } from 'uuid';

const dynamodb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE_NAME = process.env.USERS_TABLE!;

export const handler = createHandler({
  body: CreateUserRequestSchema,
  response: UserResponseSchema
}, async ({ body }) => {
  const userId = uuidv4();
  const now = new Date().toISOString();

  const user = {
    id: userId,
    ...body,
    role: 'user' as const,
    metadata: {
      createdAt: now,
      updatedAt: now
    }
  };

  await dynamodb.send(new PutCommand({
    TableName: TABLE_NAME,
    Item: {
      PK: `USER#${userId}`,
      SK: `USER#${userId}`,
      ...user
    },
    ConditionExpression: 'attribute_not_exists(PK)'
  }));

  return user;
});

// lambda/handlers/listUsers.ts
import { createHandler } from '../../lib/api/handler';
import { ListUsersQuerySchema, UsersListResponseSchema } from '../../lib/api/schemas/user';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';

const dynamodb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE_NAME = process.env.USERS_TABLE!;

export const handler = createHandler({
  query: ListUsersQuerySchema,
  response: UsersListResponseSchema
}, async ({ query }) => {
  const { limit, offset, role, sortBy, sortOrder } = query;

  // In production, implement proper pagination with DynamoDB
  const result = await dynamodb.send(new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: 'begins_with(PK, :pk)',
    ExpressionAttributeValues: {
      ':pk': 'USER#'
    },
    Limit: limit + 1, // Fetch one extra to check hasMore
    ScanIndexForward: sortOrder === 'asc'
  }));

  const items = result.Items || [];
  const hasMore = items.length > limit;
  const users = items.slice(0, limit).map(item => ({
    id: item.id,
    email: item.email,
    username: item.username,
    fullName: item.fullName,
    age: item.age,
    role: item.role,
    metadata: item.metadata,
    preferences: item.preferences
  }));

  return {
    users,
    pagination: {
      total: result.Count || 0,
      limit,
      offset,
      hasMore
    }
  };
});

// lambda/handlers/getUser.ts
import { createHandler } from '../../lib/api/handler';
import { UserIdSchema, UserResponseSchema } from '../../lib/api/schemas/user';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import { z } from 'zod';

const dynamodb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE_NAME = process.env.USERS_TABLE!;

export const handler = createHandler({
  path: z.object({ userId: UserIdSchema }),
  response: UserResponseSchema
}, async ({ path }) => {
  const result = await dynamodb.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: {
      PK: `USER#${path.userId}`,
      SK: `USER#${path.userId}`
    }
  }));

  if (!result.Item) {
    throw new Error('User not found');
  }

  return {
    id: result.Item.id,
    email: result.Item.email,
    username: result.Item.username,
    fullName: result.Item.fullName,
    age: result.Item.age,
    role: result.Item.role,
    metadata: result.Item.metadata,
    preferences: result.Item.preferences
  };
});

Generating OpenAPI Specifications

A registry collects the same schemas the handlers already use, and the generator turns them into the spec:

// lib/api/openapi.ts
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import {
  CreateUserRequestSchema,
  UpdateUserRequestSchema,
  ListUsersQuerySchema,
  UserResponseSchema,
  UsersListResponseSchema,
  UserIdSchema
} from './schemas/user';
import { ErrorResponseSchema, ApiResponseSchema } from './types';
import { z } from 'zod';

export function generateOpenApiSpec() {
  const registry = new OpenAPIRegistry();

  // Register all schemas
  registry.register('User', UserResponseSchema);
  registry.register('CreateUserRequest', CreateUserRequestSchema);
  registry.register('UpdateUserRequest', UpdateUserRequestSchema);
  registry.register('ErrorResponse', ErrorResponseSchema);

  // Define paths
  registry.registerPath({
    method: 'post',
    path: '/users',
    summary: 'Create a new user',
    tags: ['Users'],
    request: {
      body: {
        content: {
          'application/json': {
            schema: CreateUserRequestSchema
          }
        }
      }
    },
    responses: {
      200: {
        description: 'User created successfully',
        content: {
          'application/json': {
            schema: ApiResponseSchema(UserResponseSchema)
          }
        }
      },
      400: {
        description: 'Validation error',
        content: {
          'application/json': {
            schema: ApiResponseSchema(z.never()).extend({
              error: ErrorResponseSchema
            })
          }
        }
      }
    }
  });

  registry.registerPath({
    method: 'get',
    path: '/users',
    summary: 'List users',
    tags: ['Users'],
    request: {
      query: ListUsersQuerySchema
    },
    responses: {
      200: {
        description: 'Users retrieved successfully',
        content: {
          'application/json': {
            schema: ApiResponseSchema(UsersListResponseSchema)
          }
        }
      }
    }
  });

  registry.registerPath({
    method: 'get',
    path: '/users/{userId}',
    summary: 'Get user by ID',
    tags: ['Users'],
    request: {
      params: z.object({
        userId: UserIdSchema
      })
    },
    responses: {
      200: {
        description: 'User retrieved successfully',
        content: {
          'application/json': {
            schema: ApiResponseSchema(UserResponseSchema)
          }
        }
      },
      404: {
        description: 'User not found',
        content: {
          'application/json': {
            schema: ApiResponseSchema(z.never()).extend({
              error: ErrorResponseSchema
            })
          }
        }
      }
    }
  });

  // Generate OpenAPI document
  const generator = new OpenApiGeneratorV3(registry.definitions);

  return generator.generateDocument({
    openapi: '3.0.0',
    info: {
      version: '1.0.0',
      title: 'User Management API',
      description: 'Type-safe serverless API with automatic OpenAPI generation'
    },
    servers: [
      {
        url: 'https://api.example.com',
        description: 'Production'
      }
    ]
  });
}

// Build script to generate spec file
if (require.main === module) {
  const fs = require('fs');
  const path = require('path');

  const spec = generateOpenApiSpec();
  const outputPath = path.join(__dirname, '../../openapi.json');

  fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2));
  console.log(`OpenAPI spec generated at: ${outputPath}`);
}

CDK Infrastructure

Now let’s wire everything together with AWS CDK:

// lib/stacks/api-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as path from 'path';
import { generateOpenApiSpec } from '../api/openapi';

export class ApiStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // DynamoDB table
    const usersTable = new dynamodb.Table(this, 'UsersTable', {
      partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      pointInTimeRecovery: true
    });

    // Common Lambda environment
    const environment = {
      USERS_TABLE: usersTable.tableName,
      API_VERSION: '1.0.0',
      NODE_OPTIONS: '--enable-source-maps'
    };

    // Lambda functions
    const createUserFn = new NodejsFunction(this, 'CreateUserFunction', {
      entry: path.join(__dirname, '../../lambda/handlers/createUser.ts'),
      runtime: lambda.Runtime.NODEJS_20_X,
      architecture: lambda.Architecture.ARM_64,
      environment,
      bundling: {
        minify: true,
        sourceMap: true,
        sourcesContent: false,
        target: 'es2022',
        format: 'esm'
      }
    });

    const listUsersFn = new NodejsFunction(this, 'ListUsersFunction', {
      entry: path.join(__dirname, '../../lambda/handlers/listUsers.ts'),
      runtime: lambda.Runtime.NODEJS_20_X,
      architecture: lambda.Architecture.ARM_64,
      environment
    });

    const getUserFn = new NodejsFunction(this, 'GetUserFunction', {
      entry: path.join(__dirname, '../../lambda/handlers/getUser.ts'),
      runtime: lambda.Runtime.NODEJS_20_X,
      architecture: lambda.Architecture.ARM_64,
      environment
    });

    // Grant permissions
    usersTable.grantReadWriteData(createUserFn);
    usersTable.grantReadData(listUsersFn);
    usersTable.grantReadData(getUserFn);

    // API Gateway
    const api = new apigateway.RestApi(this, 'UserApi', {
      restApiName: 'User Management API',
      description: 'Type-safe API with Zod and OpenAPI',
      deployOptions: {
        stageName: 'prod',
        tracingEnabled: true,
        loggingLevel: apigateway.MethodLoggingLevel.INFO,
        dataTraceEnabled: true
      },
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: apigateway.Cors.ALL_METHODS
      }
    });

    // API resources
    const users = api.root.addResource('users');
    const user = users.addResource('{userId}');

    // Wire up endpoints
    users.addMethod('POST', new apigateway.LambdaIntegration(createUserFn));
    users.addMethod('GET', new apigateway.LambdaIntegration(listUsersFn));
    user.addMethod('GET', new apigateway.LambdaIntegration(getUserFn));

    // Generate and export OpenAPI spec
    const openApiSpec = generateOpenApiSpec();
    new cdk.CfnOutput(this, 'OpenApiSpec', {
      value: JSON.stringify(openApiSpec),
      description: 'OpenAPI specification for the API'
    });

    // Export API URL
    new cdk.CfnOutput(this, 'ApiUrl', {
      value: api.url,
      description: 'API Gateway URL'
    });
  }
}

Advanced Patterns

Middleware System

Create reusable middleware for common concerns:

// lib/api/middleware.ts
export interface MiddlewareContext<T> {
  event: T;
  context: Context;
  next: () => Promise<any>;
}

export type Middleware<T = any> = (
  ctx: MiddlewareContext<T>
) => Promise<void>;

export function compose<T>(...middlewares: Middleware<T>[]): Middleware<T> {
  return async (ctx: MiddlewareContext<T>) => {
    let index = -1;

    async function dispatch(i: number): Promise<void> {
      if (i <= index) throw new Error('next() called multiple times');
      index = i;

      const middleware = middlewares[i];
      if (!middleware) return;

      await middleware({
        ...ctx,
        next: () => dispatch(i + 1)
      });
    }

    await dispatch(0);
  };
}

// Auth middleware
export const authMiddleware: Middleware = async (ctx) => {
  const token = ctx.event.headers?.authorization?.replace('Bearer ', '');

  if (!token) {
    throw new Error('Unauthorized');
  }

  // Verify token (example with Cognito)
  const payload = await verifyToken(token);
  ctx.event.user = payload;

  await ctx.next();
};

// Rate limiting middleware
export const rateLimitMiddleware: Middleware = async (ctx) => {
  const ip = ctx.event.requestContext.identity.sourceIp;
  const key = `rate-limit:${ip}`;

  // Check rate limit (Redis/DynamoDB)
  const count = await incrementCounter(key);

  if (count > 100) {
    throw new Error('Rate limit exceeded');
  }

  await ctx.next();
};

Schema Composition

Build complex schemas from reusable parts:

// lib/api/schemas/common.ts
import { z } from 'zod';

// Pagination mixin
export const PaginationSchema = z.object({
  page: z.number().int().min(1).default(1),
  pageSize: z.number().int().min(1).max(100).default(20),
  sortBy: z.string().optional(),
  sortOrder: z.enum(['asc', 'desc']).default('asc')
});

// Timestamps mixin
export const TimestampsSchema = z.object({
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
  deletedAt: z.string().datetime().nullable().optional()
});

// Audit fields mixin
export const AuditFieldsSchema = z.object({
  createdBy: z.string().uuid(),
  updatedBy: z.string().uuid(),
  version: z.number().int().min(0)
});

// Resource schema factory
export function createResourceSchema<T extends z.ZodRawShape>(
  name: string,
  shape: T
) {
  return z.object({
    id: z.string().uuid(),
    type: z.literal(name.toLowerCase()),
    attributes: z.object(shape),
    metadata: TimestampsSchema.merge(AuditFieldsSchema)
  }).openapi(name);
}

// Usage
export const ProductResourceSchema = createResourceSchema('Product', {
  name: z.string().min(1).max(255),
  description: z.string().optional(),
  price: z.number().positive(),
  currency: z.enum(['USD', 'EUR', 'GBP']),
  inventory: z.object({
    quantity: z.number().int().min(0),
    reserved: z.number().int().min(0).default(0),
    available: z.number().int().min(0)
  })
});

Performance Optimization

Optimize cold starts with lazy loading:

// lib/api/lazy.ts
export class LazyContainer<T> {
  private instance?: T;
  private initializer: () => T;

  constructor(initializer: () => T) {
    this.initializer = initializer;
  }

  get(): T {
    if (!this.instance) {
      this.instance = this.initializer();
    }
    return this.instance;
  }
}

// Usage in Lambda
const dynamoClient = new LazyContainer(
  () => DynamoDBDocumentClient.from(new DynamoDBClient({}))
);

export const handler = createHandler({
  // ... config
}, async ({ body }) => {
  const client = dynamoClient.get();
  // Use client
});

Testing Strategies

Unit Testing Schemas

// tests/schemas/user.test.ts
import { CreateUserRequestSchema } from '../../lib/api/schemas/user';

describe('CreateUserRequestSchema', () => {
  it('validates correct input', () => {
    const result = CreateUserRequestSchema.safeParse({
      email: '[email protected]',
      username: 'test_user',
      fullName: 'Test User',
      age: 25
    });

    expect(result.success).toBe(true);
  });

  it('rejects invalid email', () => {
    const result = CreateUserRequestSchema.safeParse({
      email: 'not-an-email',
      username: 'test_user',
      fullName: 'Test User'
    });

    expect(result.success).toBe(false);
    expect(result.error?.issues[0].path).toEqual(['email']);
  });

  it('enforces username constraints', () => {
    const result = CreateUserRequestSchema.safeParse({
      email: '[email protected]',
      username: 'a', // Too short
      fullName: 'Test User'
    });

    expect(result.success).toBe(false);
    expect(result.error?.issues[0].message).toContain('at least 3');
  });
});

Integration Testing

// tests/integration/createUser.test.ts
import { handler } from '../../lambda/handlers/createUser';
import { APIGatewayProxyEventV2, Context } from 'aws-lambda';

describe('Create User Handler', () => {
  it('creates user with valid input', async () => {
    const event: Partial<APIGatewayProxyEventV2> = {
      body: JSON.stringify({
        email: '[email protected]',
        username: 'test_user',
        fullName: 'Test User'
      }),
      headers: {
        'content-type': 'application/json'
      }
    };

    const context: Partial<Context> = {
      requestId: '123',
      functionName: 'createUser'
    };

    const response = await handler(
      event as APIGatewayProxyEventV2,
      context as Context
    );

    expect(response.statusCode).toBe(200);

    const body = JSON.parse(response.body!);
    expect(body.success).toBe(true);
    expect(body.data.email).toBe('[email protected]');
    expect(body.data.id).toBeDefined();
  });

  it('returns validation error for invalid input', async () => {
    const event: Partial<APIGatewayProxyEventV2> = {
      body: JSON.stringify({
        email: 'invalid-email',
        username: 'test_user'
      })
    };

    const response = await handler(
      event as APIGatewayProxyEventV2,
      {} as Context
    );

    expect(response.statusCode).toBe(400);

    const body = JSON.parse(response.body!);
    expect(body.success).toBe(false);
    expect(body.error.details).toBeDefined();
  });
});

Deployment and CI/CD

GitHub Actions Workflow

# .github/workflows/deploy.yml
name: Deploy API

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Type check
        run: npm run typecheck

      - name: Generate OpenAPI spec
        run: npm run generate:openapi

      - name: Upload OpenAPI spec
        uses: actions/upload-artifact@v3
        with:
          name: openapi-spec
          path: openapi.json

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Deploy CDK
        run: npx cdk deploy --require-approval never

Monitoring and Observability

Add structured logging and tracing:

// lib/api/observability.ts
import middy from '@middy/core';
import { Tracer } from '@aws-lambda-powertools/tracer';
import { captureLambdaHandler } from '@aws-lambda-powertools/tracer/middleware';
import { Logger } from '@aws-lambda-powertools/logger';
import { injectLambdaContext } from '@aws-lambda-powertools/logger/middleware';
import { Metrics } from '@aws-lambda-powertools/metrics';
import { logMetrics } from '@aws-lambda-powertools/metrics/middleware';

const tracer = new Tracer();
const logger = new Logger();
const metrics = new Metrics();

export function createObservableHandler<T extends HandlerConfig<any, any, any, any>>(
  config: T,
  handler: HandlerFunction<T>
) {
  const baseHandler = createHandler(config, handler);

  // The class methods (tracer.captureLambdaHandler and friends) are class
  // decorators. The wrappable versions live in the /middleware subpaths.
  return middy(baseHandler)
    .use(captureLambdaHandler(tracer))
    .use(injectLambdaContext(logger))
    .use(logMetrics(metrics));
}

// Usage
export const handler = createObservableHandler({
  body: CreateUserRequestSchema,
  response: UserResponseSchema
}, async ({ body }, context) => {
  logger.info('Creating user', { email: body.email });

  // Add custom metric
  metrics.addMetric('UserCreated', 'Count', 1);

  // Add trace annotation
  tracer.putAnnotation('userEmail', body.email);

  // Business logic...
});

Best Practices

1. Schema Versioning

// lib/api/schemas/v1/user.ts
export const UserSchemaV1 = z.object({
  // V1 schema
});

// lib/api/schemas/v2/user.ts
export const UserSchemaV2 = UserSchemaV1.extend({
  // V2 additions
  preferences: PreferencesSchema
});

// Handler with version support
export const handler = createHandler({
  headers: z.object({
    'api-version': z.enum(['v1', 'v2']).default('v2')
  }),
  body: z.union([UserSchemaV1, UserSchemaV2]),
  response: z.union([UserResponseV1, UserResponseV2])
}, async ({ headers, body }) => {
  if (headers['api-version'] === 'v1') {
    return handleV1(body);
  }
  return handleV2(body);
});

2. Error Recovery

export const resilientHandler = createHandler({
  // ... config
}, async ({ body }, context) => {
  // Circuit breaker pattern
  const breaker = new CircuitBreaker(dynamoClient.send, {
    timeout: 3000,
    errorThresholdPercentage: 50,
    resetTimeout: 30000
  });

  try {
    return await breaker.fire(new PutCommand({
      TableName: TABLE_NAME,
      Item: user
    }));
  } catch (error) {
    // Fallback to SQS
    await sqsClient.send(new SendMessageCommand({
      QueueUrl: DLQ_URL,
      MessageBody: JSON.stringify({ user, error: error.message })
    }));

    throw new Error('Service temporarily unavailable');
  }
});

3. Security Headers

export function addSecurityHeaders(response: APIGatewayProxyResultV2): APIGatewayProxyResultV2 {
  return {
    ...response,
    headers: {
      ...response.headers,
      'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
      'X-Content-Type-Options': 'nosniff',
      'X-Frame-Options': 'DENY',
      'X-XSS-Protection': '1; mode=block',
      'Referrer-Policy': 'strict-origin-when-cross-origin',
      'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'"
    }
  };
}

4. Schema Evolution

Schema versioning is the contract. The concrete distinction is between additive and breaking changes. Optional fields extend a schema without affecting existing clients. A new required field is a breaking change and needs a new version.

// Adding fields - backward compatible
const CreateUserRequestSchemaV2 = CreateUserRequestSchema.extend({
  // New optional fields are safe
  phoneNumber: z.string().optional(),
  preferences: z.object({
    newsletter: z.boolean(),
    notifications: z.boolean(),
  }).optional(),
});

// Versioning for breaking changes
const CreateUserRequestSchemaV3 = z.object({
  // Adding a required field is a breaking change
  email: z.string().email(),
  username: z.string().min(3),
  fullName: z.string().min(1), // New required field - V3
});

5. Error Translation

A standalone translator keeps the mapping from failure to HTTP status in one place. Validation errors become 400, conditional write failures become 409, missing resources become 404, and anything unexpected falls back to 500 without leaking internals.

// lib/api/error-handler.ts
export const handleApiError = (error: unknown): APIGatewayProxyResultV2 => {
  console.error('API Error:', error);

  // Zod validation errors
  if (error instanceof z.ZodError) {
    return {
      statusCode: 400,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        error: 'Validation failed',
        details: error.issues.map(e => ({
          field: e.path.join('.'),
          message: e.message,
          received: e.input,
        })),
      }),
    };
  }

  // AWS errors
  if (error && typeof error === 'object' && 'name' in error) {
    switch (error.name) {
      case 'ConditionalCheckFailedException':
        return {
          statusCode: 409,
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ error: 'Resource already exists' }),
        };

      case 'ResourceNotFoundException':
        return {
          statusCode: 404,
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ error: 'Resource not found' }),
        };
    }
  }

  // Generic server error - log the stack trace but never return it to the client
  return {
    statusCode: 500,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ error: 'Internal server error' }),
  };
};

6. Validation Memoization

Defining schemas at module scope keeps them off the hot path on every invocation. A small, bounded cache short-circuits repeated validation of identical payloads, which helps when the same input is parsed more than once per request.

// Define schemas at global scope - better for cold start performance
const SCHEMAS = {
  createUser: CreateUserRequestSchema,
  user: UserResponseSchema,
} as const;

// Cache validation results per schema at runtime; keying by schema identity
// stops two different schemas that accept the same payload from sharing a result
const MAX_CACHE_ENTRIES = 500;
const validationCache = new WeakMap<z.ZodSchema, Map<string, unknown>>();

export const validateWithCache = <T>(schema: z.ZodSchema<T>, data: unknown): T => {
  let schemaCache = validationCache.get(schema);
  if (!schemaCache) {
    schemaCache = new Map();
    validationCache.set(schema, schemaCache);
  }

  const cacheKey = JSON.stringify(data);
  if (schemaCache.has(cacheKey)) {
    return schemaCache.get(cacheKey) as T;
  }

  const result = schema.parse(data);

  // Bound the cache so a warm container cannot leak memory on unique payloads.
  // Map preserves insertion order, so the oldest key is evicted first.
  if (schemaCache.size >= MAX_CACHE_ENTRIES) {
    const oldestKey = schemaCache.keys().next().value as string | undefined;
    if (oldestKey !== undefined) {
      schemaCache.delete(oldestKey);
    }
  }
  schemaCache.set(cacheKey, result);

  return result;
};

Where This Default Holds

Code-first with Zod is the right default when the API has external consumers who generate clients from the spec, and when one TypeScript codebase owns validation, handlers, and infrastructure. The spec becomes a build artifact instead of a document somebody has to remember to update.

Two situations call for something else. If the contract is reviewed and approved before implementation, the OpenAPI document is the source of truth, so generate types from it rather than the other way around. And if the API is a handful of internal endpoints with one known caller, the wrapper, the registry, and the generation step cost more than the drift they prevent; parsing with Zod at the handler boundary is enough on its own.

From here the usual additions are Cognito or a custom JWT verifier behind config.auth, API Gateway request validation driven by the generated spec, and contract testing so a breaking schema change fails in CI rather than in a consumer’s integration.

References

Related posts