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#
Picture this: you're reviewing Lambda functions across your team, and every single one starts with the same 40 lines of validation, error handling, and CORS setup. Sound familiar? I've been there, staring at what felt like a copy-paste festival gone wrong.
This was our reality managing 30+ Lambda functions for a fintech application. Every endpoint needed authentication, input validation, proper error responses, and security headers. Writing this boilerplate repeatedly wasn't just tedious—it was becoming a maintenance nightmare and a breeding ground for subtle bugs.
That's when we discovered Middy, and honestly, 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:
// 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 { 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. Your business logic becomes the star of the show, while all the HTTP concerns are handled consistently by battle-tested middleware.
Essential Middy Middlewares#
After working with dozens of Lambda functions, here are the middlewares I consider essential:
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
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:
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:
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#
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:
// 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 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. Here's my standard configuration:
.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:
.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:
// Test the pure business logic
describe('User Registration Logic', () => {
test('should create new user with valid data', async () => {
const mockEvent = {
body: {
email: 'test@example.com',
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('test@example.com')
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: 'test@example.com'
// 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:
- 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#
Ready to try Middy? Here's your starter kit:
# 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
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.
AWS Lambda Middleware Mastery
From Middy basics to building custom middleware frameworks for production-scale Lambda applications
All Posts in This Series
Comments (0)
Join the conversation
Sign in to share your thoughts and engage with the community
No comments yet
Be the first to share your thoughts on this post!
Comments (0)
Join the conversation
Sign in to share your thoughts and engage with the community
No comments yet
Be the first to share your thoughts on this post!