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
Lambda handlers tend to grow the same preamble: parse the JSON body, validate it, catch errors, attach CORS and security headers. Every endpoint repeats it, and every copy drifts a little from the last one.
Middy is the pragmatic default for Node.js handlers behind API Gateway. It wraps a handler in composable middleware, so parsing, validation, error shaping, and headers live in one shared chain instead of in each function. The cost is an extra dependency and a little cold-start weight, which is why a few function shapes are still better off without it.
What is Middy?
Think of Middy as the middleware system you know from Express or Koa, redesigned 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:
// Without Middy - The old way
export 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' })
}
}
}
// With Middy - Clean and focused
import 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 logic
const baseHandler = async (event: APIGatewayProxyEvent) => {
const { name } = event.body as unknown 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 second version puts the business logic first and leaves the HTTP concerns to middleware that every function shares.
Essential Middy Middlewares
Here are the middlewares that prove most valuable in Lambda development:
HTTP Basics
import httpJsonBodyParser from '@middy/http-json-body-parser' // Parses JSON bodies
import httpErrorHandler from '@middy/http-error-handler' // Converts errors to HTTP responses
import httpEventNormalizer from '@middy/http-event-normalizer' // Normalizes API Gateway events
import httpResponseSerializer from '@middy/http-response-serializer' // Handles response serialization
Security & CORS
import httpSecurityHeaders from '@middy/http-security-headers' // Adds security headers
import httpCors from '@middy/http-cors' // Handles CORS
Validation
import validator from '@middy/validator' // JSON Schema validation
AWS Service Integration
import ssm from '@middy/ssm' // AWS Systems Manager parameters
import secretsManager from '@middy/secrets-manager' // AWS Secrets Manager
import warmup from '@middy/warmup' // Lambda warmup handling
Example: User Registration API
The pieces come together in a registration endpoint that validates input, sets headers, and turns domain failures into HTTP responses:
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 unknown 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({
strictTransportSecurity: {
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
Writing Custom Middleware
Sometimes you need something specific to your application. Creating custom middleware is straightforward once you understand the pattern:
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
})
}
}
}
}
// Usage
export 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
A few habits keep a middleware chain predictable as the number of functions grows:
1. Order Matters
Middleware execution order is crucial. Incorrect ordering causes subtle bugs:
// Wrong order - validator runs before body parsing
export const handler = middy(baseHandler)
.use(validator({ eventSchema: schema })) // This will fail!
.use(httpJsonBodyParser())
.use(httpErrorHandler())
// Correct order
export 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:
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 unknown as UserRegistration
// ... rest of your logic
}
3. Error Handling Strategy
Create domain-specific error classes:
class BusinessLogicError extends Error {
statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.statusCode = statusCode
this.name = 'BusinessLogicError'
}
}
// Use in handlers
if (!isValidBusinessRule(data)) {
throw new BusinessLogicError('Invalid business data', 422)
}
4. Security Headers Should be Standard
Don’t skip security headers. A standard configuration:
.use(httpSecurityHeaders({
contentTypeOptions: { action: 'nosniff' },
frameOptions: { action: 'deny' },
contentSecurityPolicy: { 'default-src': "'self'" },
strictTransportSecurity: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}))
5. Cache Configuration Data
For frequently called functions, cache expensive configuration:
.use(ssm({
fetchData: {
dbConfig: '/myapp/database/config',
apiKeys: '/myapp/external/api-keys'
},
cacheExpiry: 5 * 60 * 1000, // 5 minutes
setToContext: true
}))
Testing Middy Functions
One of Middy’s biggest advantages is how it improves testability. You can test your business logic separately from the middleware:
// Test the pure business logic
describe('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 unknown as APIGatewayProxyEvent
// Test the core handler directly
const result = await registerUser(mockEvent)
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 chain
describe('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
Watch out for these common issues:
- Over-engineering simple functions - Not every Lambda needs middleware
- Ignoring middleware order - Parse before validate, validate before business logic
- Heavy middlewares in cold starts - Be mindful of initialization overhead
- Logging sensitive data - Be careful with input/output logging middleware
- Not caching configuration - Use built-in caching for external data
Getting Started
A starter kit for Middy:
# Core package
npm install @middy/core
# Essential middlewares
npm install @middy/http-json-body-parser @middy/http-error-handler @middy/validator
# Security & CORS
npm install @middy/http-cors @middy/http-security-headers
# Performance utilities
npm install @middy/do-not-wait-for-empty-event-loop @middy/warmup
# AWS service integrations
npm install @middy/ssm @middy/secrets-manager
Add the parser, validator, and error handler first, then extend the chain once the handler shape is stable. A function with no shared preamble to hoist is better left as a plain handler.
What’s Next?
Middy covers most HTTP handlers well. Part 2 covers the limits that show up at scale, and how a custom middleware framework handles multi-tenant configuration and tighter performance budgets.
The next part covers:
- Performance bottlenecks that surface at scale
- Building dynamic middleware for multi-tenant applications
- Custom framework design patterns
- Migration strategies from Middy to custom solutions
- Performance trade-offs between Middy and a hand-rolled chain
References
- Middy.js - Official Documentation - Complete middleware engine documentation covering core concepts, official packages, and lifecycle hooks
- Middy - Official Middlewares - Catalogue of the officially maintained middleware packages including validator, http-error-handler, and AWS service integrations
- Middy - Getting Started - Practical introduction to wrapping Lambda handlers and attaching middleware
- Building Lambda functions with Node.js - AWS Lambda - AWS official reference for the Node.js Lambda runtime, handler model, and supported features
- Powertools for AWS Lambda (TypeScript) - AWS toolkit for structured logging, tracing, metrics, and validation that complements Middy-based handlers
- middy/core - npm - npm package page with version history, install instructions, and weekly download stats
AWS Lambda Middleware Mastery
From Middy basics to building custom middleware frameworks for production-scale Lambda applications
All Posts in This Series
Related posts
Build maintainable, type-safe Lambda middleware with Middy's builder pattern, Zod validation, feature flags, and secrets management for serverless apps.
When a Lambda fleet outgrows Middy's static middleware model, how a project-specific engine handles per-request config, and what owning one costs
A practical comparison of TypeScript AI SDKs for building agents: Vercel AI SDK, OpenAI Agents SDK, and AWS Bedrock, with code examples and decision frameworks.
A practical guide to learning Effect incrementally and integrating it with AWS Lambda, with real code examples, common pitfalls, and production patterns.
A practical guide to the CloudEvents spec and TypeScript SDK: create, parse, and validate standardized events across AWS Lambda and EventBridge.