Skip to content

Zod + OpenAPI + AWS Lambda: How Documentation Drift Led Me to Schema-First Development

How a 'simple' API change broke an enterprise client integration overnight, why documentation drift causes real problems, and a practical system that generates OpenAPI specs from Zod schemas automatically.

A painful lesson about API documentation drift: adding an optional field to a user API without updating the OpenAPI spec broke an enterprise client's integration overnight. Their code generation pipeline produced TypeScript interfaces expecting the old schema, resulting in hundreds of failed user registrations and significant revenue loss.

This incident highlighted that API documentation isn't just nice-to-have - it's critical business infrastructure. This approach involves rebuilding systems to generate OpenAPI specs automatically from Zod schemas, enabling safer API evolution.

The Critical Lesson: Why Documentation Drift Kills Businesses

Before our incident, we had the classic serverless API development problem: four different sources of truth:

typescript
// 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 different definitions. Four opportunities for bugs. One angry enterprise client.

The pain wasn't just the immediate cost - it was the follow-up meetings, the trust damage, and the realization that our development approach needed fundamental changes.

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)

Benefits observed in production:

  • Reduced integration failures from schema drift
  • Less time spent on manual spec maintenance
  • Smoother client onboarding with accurate, auto-generated SDKs
  • Clearer error messages leading to fewer support questions

Here's the architecture:

The Foundation That Prevents Schema Drift

Here's the setup that supports safe API evolution:

bash
# Core dependenciesnpm install zod @anatine/zod-openapi @asteasolutions/zod-to-openapinpm install uuid @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodbnpm install --save-dev @types/aws-lambda @types/uuid
# For production monitoringnpm install @aws-lambda-powertools/logger @aws-lambda-powertools/tracer @aws-lambda-powertools/metrics

The Type System That Catches Bugs at Compile Time

Here's a foundation that helps prevent schema drift:

typescript
// lib/api/types.ts - Core schema definitionsimport { z } from 'zod';import { extendZodWithOpenApi } from '@anatine/zod-openapi';import {  APIGatewayProxyEventV2,  APIGatewayProxyResultV2,  Context} from 'aws-lambda';
// Extend Zod with OpenAPI capabilitiesextendZodWithOpenApi(z);
// Error schema for structured error responsesexport 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 experienceexport 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 routesexport 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;  // Added after learning from production issues  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 IntelliSenseexport 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 That Validates API Contracts

This wrapper provides comprehensive request validation and error handling:

typescript
// lib/api/handler.ts - Type-safe request/response wrapperimport { 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;  // Added after auth integration    },    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();
    // 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 (learned from security incidents)      let userId: string | undefined;      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',          // Security headers learned from production          '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 (these save support tickets)      if (error instanceof ZodError) {        logger.warn('Validation error', {          requestId,          errors: error.errors,          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.errors.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 validationasync 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:

typescript
// lib/api/schemas/user.tsimport { z } from 'zod';import { extendZodWithOpenApi } from '@anatine/zod-openapi';
extendZodWithOpenApi(z);
// Shared schemasexport 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 entityexport 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 schemasexport 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 schemasexport 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:

typescript
// lambda/handlers/createUser.tsimport { 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.tsimport { 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.tsimport { 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

The real magic happens when we automatically generate OpenAPI specs from our Zod schemas:

typescript
// lib/api/openapi.tsimport { 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 fileif (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:

typescript
// lib/stacks/api-stack.tsimport * 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:

typescript
// lib/api/middleware.tsexport 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 middlewareexport 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 middlewareexport 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:

typescript
// lib/api/schemas/common.tsimport { z } from 'zod';
// Pagination mixinexport 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 mixinexport const TimestampsSchema = z.object({  createdAt: z.string().datetime(),  updatedAt: z.string().datetime(),  deletedAt: z.string().datetime().nullable().optional()});
// Audit fields mixinexport const AuditFieldsSchema = z.object({  createdBy: z.string().uuid(),  updatedBy: z.string().uuid(),  version: z.number().int().min(0)});
// Resource schema factoryexport 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);}
// Usageexport 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:

typescript
// lib/api/lazy.tsexport 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 Lambdaconst 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

typescript
// tests/schemas/user.test.tsimport { 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

typescript
// tests/integration/createUser.test.tsimport { 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

yaml
# .github/workflows/deploy.ymlname: 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:

typescript
// lib/api/observability.tsimport { Tracer } from '@aws-lambda-powertools/tracer';import { Logger } from '@aws-lambda-powertools/logger';import { Metrics } from '@aws-lambda-powertools/metrics';
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);
  return tracer.captureLambdaHandler(    logger.injectLambdaContext(      metrics.logMetrics(baseHandler)    )  );}
// Usageexport 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

typescript
// lib/api/schemas/v1/user.tsexport const UserSchemaV1 = z.object({  // V1 schema});
// lib/api/schemas/v2/user.tsexport const UserSchemaV2 = UserSchemaV1.extend({  // V2 additions  preferences: PreferencesSchema});
// Handler with version supportexport 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

typescript
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

typescript
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'"    }  };}

Conclusion

By combining Zod's runtime validation with OpenAPI generation, this approach creates a type-safe serverless API that:

  • Eliminates manual synchronization between types, validation, and documentation
  • Catches errors at compile time with full TypeScript integration
  • Validates at runtime with detailed error messages
  • Generates accurate documentation automatically
  • Scales efficiently with AWS Lambda and CDK

This approach transforms API development from error-prone manual coordination to a streamlined, automated process. Your schemas become the single source of truth, ensuring consistency across every layer of your serverless stack.

Next Steps

  • Add authentication with AWS Cognito or custom JWT validation
  • Implement caching with API Gateway caching or ElastiCache
  • Add WebSocket support for real-time features
  • Integrate with AWS X-Ray for distributed tracing
  • Set up API versioning with stage variables
  • Add contract testing with Pact or similar tools

The foundation we've built handles the complexity of modern API development while maintaining the simplicity that makes serverless attractive. Happy building!

References

Related Posts