Skip to content

AWS Lambda Middleware with Middy - Clean Code and Best Practices

Discover how Middy transforms Lambda development with middleware patterns, moving from repetitive boilerplate to clean, maintainable serverless functions

AWS Lambda Middleware with Middy - Clean Code and Best Practices

When reviewing Lambda functions across a team, a common pattern emerges: every function starts with the same 40 lines of validation, error handling, and CORS setup. This repetitive boilerplate becomes a maintenance challenge.

Managing multiple Lambda functions often involves this challenge. Every endpoint needs authentication, input validation, proper error responses, and security headers. Writing this boilerplate repeatedly isn't just tedious - it becomes a maintenance challenge and source of potential bugs.

That's when we discovered Middy, and it changed how we write Lambda functions entirely.

What is Middy?

Think of Middy like the middleware system you know from Express or Koa, but designed specifically for AWS Lambda. It takes the onion-layer approach where your business logic sits at the center, surrounded by reusable middleware that handles the boring but essential stuff.

Instead of cramming everything into your handler function, Middy lets you compose clean, focused functions:

typescript
// Without Middy - The old wayexport const handler = async (event: APIGatewayProxyEvent) => {  try {    // Parse JSON body    let body    try {      body = JSON.parse(event.body || '{}')    } catch (e) {      return {        statusCode: 400,        headers: { 'Access-Control-Allow-Origin': '*' },        body: JSON.stringify({ error: 'Invalid JSON' })      }    }
    // Validate input    if (!body.name || typeof body.name !== 'string') {      return {        statusCode: 400,        headers: { 'Access-Control-Allow-Origin': '*' },        body: JSON.stringify({ error: 'Name is required' })      }    }
    // Add security headers    const headers = {      'Access-Control-Allow-Origin': '*',      'X-Content-Type-Options': 'nosniff',      'X-Frame-Options': 'DENY'    }
    // Finally, your business logic    const greeting = `Hello, ${body.name}!`
    return {      statusCode: 200,      headers,      body: JSON.stringify({ message: greeting })    }  } catch (error) {    console.error('Error:', error)    return {      statusCode: 500,      headers: { 'Access-Control-Allow-Origin': '*' },      body: JSON.stringify({ error: 'Internal server error' })    }  }}
typescript
// With Middy - Clean and focusedimport middy from '@middy/core'import httpJsonBodyParser from '@middy/http-json-body-parser'import httpErrorHandler from '@middy/http-error-handler'import httpCors from '@middy/http-cors'import httpSecurityHeaders from '@middy/http-security-headers'import validator from '@middy/validator'import { transpileSchema } from '@middy/validator/transpile'
// Pure business logicconst baseHandler = async (event: APIGatewayProxyEvent) => {  const { name } = event.body as { name: string }    return {    statusCode: 200,    body: JSON.stringify({       message: `Hello, ${name}!`,      timestamp: new Date().toISOString()    })  }}
const schema = {  type: 'object',  properties: {    body: {      type: 'object',      properties: {        name: { type: 'string', minLength: 1, maxLength: 100 }      },      required: ['name']    }  }}
export const handler = middy(baseHandler)  .use(httpJsonBodyParser())  .use(validator({ eventSchema: transpileSchema(schema) }))  .use(httpCors({ origin: '*' }))  .use(httpSecurityHeaders())  .use(httpErrorHandler())

The difference is striking. Business logic becomes the focus, while all the HTTP concerns are handled consistently by proven middleware.

Essential Middy Middlewares

Here are the middlewares that prove most valuable in Lambda development:

HTTP Basics

typescript
import httpJsonBodyParser from '@middy/http-json-body-parser'  // Parses JSON bodiesimport httpErrorHandler from '@middy/http-error-handler'  // Converts errors to HTTP responsesimport httpEventNormalizer from '@middy/http-event-normalizer'  // Normalizes API Gateway eventsimport httpResponseSerializer from '@middy/http-response-serializer' // Handles response serialization

Security & CORS

typescript
import httpSecurityHeaders from '@middy/http-security-headers'  // Adds security headersimport httpCors from '@middy/http-cors'  // Handles CORS

Validation

typescript
import validator from '@middy/validator'  // JSON Schema validation

AWS Service Integration

typescript
import ssm from '@middy/ssm'  // AWS Systems Manager parametersimport secretsManager from '@middy/secrets-manager'  // AWS Secrets Managerimport warmup from '@middy/warmup'  // Lambda warmup handling

Real-World Example: User Registration API

Let me show you how these come together in a production scenario. Here's a user registration endpoint we built that handles validation, security, and error cases gracefully:

typescript
import middy from '@middy/core'import httpJsonBodyParser from '@middy/http-json-body-parser'import httpErrorHandler from '@middy/http-error-handler'import httpSecurityHeaders from '@middy/http-security-headers'import httpCors from '@middy/http-cors'import validator from '@middy/validator'import { transpileSchema } from '@middy/validator/transpile'import { createError } from '@middy/util'
interface UserRegistration {  email: string  password: string  firstName: string  lastName: string}
const registerUser = async (event: APIGatewayProxyEvent) => {  const userData = event.body as UserRegistration    // Check if user already exists  const existingUser = await getUserByEmail(userData.email)  if (existingUser) {    throw createError(409, 'User already exists', {       type: 'UserAlreadyExists'     })  }    // Create new user  const hashedPassword = await hashPassword(userData.password)  const newUser = await createUser({    ...userData,    password: hashedPassword  })    // Send welcome email (fire and forget)  sendWelcomeEmail(newUser.email, newUser.firstName).catch(    error => console.error('Failed to send welcome email:', error)  )    return {    statusCode: 201,    body: JSON.stringify({      id: newUser.id,      email: newUser.email,      firstName: newUser.firstName,      lastName: newUser.lastName,      createdAt: newUser.createdAt    })  }}
const registrationSchema = {  type: 'object',  properties: {    body: {      type: 'object',      properties: {        email: {           type: 'string',           format: 'email',          maxLength: 254        },        password: {           type: 'string',           minLength: 8,          maxLength: 128,          pattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]'        },        firstName: {           type: 'string',           minLength: 1,          maxLength: 50        },        lastName: {           type: 'string',           minLength: 1,          maxLength: 50        }      },      required: ['email', 'password', 'firstName', 'lastName']    }  }}
export const handler = middy(registerUser)  .use(httpJsonBodyParser())  .use(validator({ eventSchema: transpileSchema(registrationSchema) }))  .use(httpCors({    origin: process.env.ALLOWED_ORIGINS?.split(',') ?? ['http://localhost:3000'],    credentials: true  }))  .use(httpSecurityHeaders({    hsts: {      maxAge: 31536000,      includeSubDomains: true    }  }))  .use(httpErrorHandler({    logger: console.error  }))

This single middleware chain handles:

  • JSON parsing with error handling
  • Comprehensive input validation (including password complexity)
  • CORS headers with configurable origins
  • Security headers for protection
  • Proper HTTP error responses
  • Request logging

Your business logic stays clean and testable, while all the HTTP concerns are handled consistently.

Writing Custom Middleware

Sometimes you need something specific to your application. Creating custom middleware is straightforward once you understand the pattern:

typescript
import { MiddlewareObj } from '@middy/core'
interface RequestTimingOptions {  logSlowRequests?: boolean  slowRequestThreshold?: number}
export const requestTiming = (  options: RequestTimingOptions = {}): MiddlewareObj => {  const { logSlowRequests = true, slowRequestThreshold = 1000 } = options    return {    before: async (request) => {      // Initialize timing      request.internal = request.internal || {}      request.internal.startTime = Date.now()    },        after: async (request) => {      if (request.internal?.startTime) {        const duration = Date.now() - request.internal.startTime                // Add timing header to response        if (request.response && typeof request.response === 'object') {          const response = request.response as any          response.headers = {            ...response.headers,            'X-Execution-Time': duration.toString()          }        }                // Log slow requests        if (logSlowRequests && duration > slowRequestThreshold) {          console.warn(`Slow request detected: ${duration}ms`, {            functionName: request.context.functionName,            requestId: request.context.awsRequestId,            duration          })        }      }    },        onError: async (request) => {      if (request.internal?.startTime) {        const duration = Date.now() - request.internal.startTime        console.error(`Request failed after ${duration}ms`, {          error: request.error?.message,          duration,          requestId: request.context.awsRequestId        })      }    }  }}
// Usageexport const handler = middy(baseHandler)  .use(requestTiming({ slowRequestThreshold: 500 }))  .use(httpJsonBodyParser())  .use(httpErrorHandler())

This custom middleware adds execution timing to responses and logs slow requests automatically. The pattern is clean: before runs before your handler, after runs after success, and onError handles failures.

Production Best Practices

Here's what I've learned from running Middy in production:

1. Order Matters

Middleware execution order is crucial. I've seen subtle bugs caused by incorrect ordering:

typescript
// Wrong order - validator runs before body parsingexport const handler = middy(baseHandler)  .use(validator({ eventSchema: schema }))  // This will fail!  .use(httpJsonBodyParser())  .use(httpErrorHandler())
// Correct orderexport const handler = middy(baseHandler)  .use(httpJsonBodyParser())  // Parse first  .use(validator({ eventSchema: schema })) // Then validate  .use(httpErrorHandler())  // Handle errors last

2. Type Safety is Essential

Always use proper TypeScript types:

typescript
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'
const typedHandler = async (  event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {  // TypeScript will catch errors at compile time  const body = event.body as UserRegistration  // ... rest of your logic}

3. Error Handling Strategy

Create domain-specific error classes:

typescript
class BusinessLogicError extends Error {  statusCode: number    constructor(message: string, statusCode = 400) {    super(message)    this.statusCode = statusCode    this.name = 'BusinessLogicError'  }}
// Use in handlersif (!isValidBusinessRule(data)) {  throw new BusinessLogicError('Invalid business data', 422)}

4. Security Headers Should be Standard

Don't skip security headers. Here's my standard configuration:

typescript
.use(httpSecurityHeaders({  contentTypeOptions: 'nosniff',  frameOptions: 'DENY',  contentSecurityPolicy: "default-src 'self'",  hsts: {    maxAge: 31536000,    includeSubDomains: true,    preload: true  }}))

5. Cache Configuration Data

For frequently called functions, cache expensive configuration:

typescript
.use(ssm({  cache: true,  cacheExpiry: 5 * 60 * 1000, // 5 minutes  names: {    dbConfig: '/myapp/database/config',    apiKeys: '/myapp/external/api-keys'  }}))

Testing Middy Functions

One of Middy's biggest advantages is how it improves testability. You can test your business logic separately from the middleware:

typescript
// Test the pure business logicdescribe('User Registration Logic', () => {  test('should create new user with valid data', async () => {    const mockEvent = {      body: {        email: '[email protected]',        password: 'SecurePass123!',        firstName: 'John',        lastName: 'Doe'      }    } as APIGatewayProxyEvent
    // Test the core handler directly    const result = await registerUser(mockEvent, {} as any)        expect(result.statusCode).toBe(201)    const responseBody = JSON.parse(result.body)    expect(responseBody.email).toBe('[email protected]')    expect(responseBody.password).toBeUndefined()  })})
// Test the full middleware chaindescribe('User Registration API', () => {  test('should handle invalid JSON', async () => {    const event = {      body: 'invalid json',      headers: { 'content-type': 'application/json' }    } as any
    const result = await handler(event, {} as any)        expect(result.statusCode).toBe(400)  })
  test('should validate required fields', async () => {    const event = {      body: JSON.stringify({        email: '[email protected]'        // Missing required fields      }),      headers: { 'content-type': 'application/json' }    } as any
    const result = await handler(event, {} as any)        expect(result.statusCode).toBe(400)  })})

When NOT to Use Middy

Middy isn't always the right choice. Skip it when:

  • Ultra low-latency functions where every millisecond counts
  • Single-purpose utilities with minimal logic
  • Memory-constrained environments where bundle size is critical
  • Framework-agnostic libraries where explicit composition is preferred

Common Pitfalls to Avoid

From our experience, watch out for these issues:

  1. Over-engineering simple functions - Not every Lambda needs middleware
  2. Ignoring middleware order - Parse before validate, validate before business logic
  3. Heavy middlewares in cold starts - Be mindful of initialization overhead
  4. Logging sensitive data - Be careful with input/output logging middleware
  5. Not caching configuration - Use built-in caching for external data

Getting Started

Ready to try Middy? Here's your starter kit:

bash
# Core packagenpm install @middy/core
# Essential middlewaresnpm install @middy/http-json-body-parser @middy/http-error-handler @middy/validator
# Security & CORSnpm install @middy/http-cors @middy/http-security-headers
# Performance utilitiesnpm install @middy/do-not-wait-for-empty-event-loop @middy/warmup
# AWS service integrationsnpm install @middy/ssm @middy/secrets-manager

Start with a simple HTTP API, add middleware incrementally, and watch your Lambda functions become more maintainable and consistent.

What's Next?

Middy is excellent for most use cases, but what happens when you need more? In Part 2, we'll explore the limitations we hit in production and how we built our own custom middleware framework to handle complex business requirements and optimize performance.

You'll learn about:

  • Performance bottlenecks we discovered at scale
  • Building dynamic middleware for multi-tenant applications
  • Custom framework design patterns
  • Migration strategies from Middy to custom solutions
  • Real performance benchmarks and trade-offs

Middy transformed how we write Lambda functions, making them cleaner, more testable, and easier to maintain. Master these patterns, and you'll write better serverless code from day one.

References

AWS Lambda Middleware Mastery

From Middy basics to building custom middleware frameworks for production-scale Lambda applications

Progress1/2 posts completed

All Posts in This Series

Part 1: Introduction to Middy - The Lambda Middleware Engine

Related Posts