Skip to content
Ayhan Sipahi Ayhan Sipahi

Middy and Zod: Type-Safe AWS Lambda Middleware Validation

Build maintainable, type-safe Lambda middleware with Middy's builder pattern, Zod validation, feature flags, and secrets management for serverless apps.

Middleware in a Lambda codebase drifts. Once a project has more than a handful of functions, every handler wires its own Middy chain: error handlers land in different positions, some endpoints authenticate and some do not, and a schema that no longer matches the handler signature fails only when traffic hits it.

A thin builder in front of Middy fixes that. It pins the middleware order in one place, carries the context types forward so TypeScript knows what the handler receives, and uses Zod instead of JSON Schema for validation. The cost is a few hundred lines of glue and a slightly heavier cold start, which is a trade worth taking for any API beyond a couple of endpoints.

The Problem with Standard Middleware Patterns

A hand-assembled chain looks fine inside one file. Across twenty handlers it stops being reviewable, because nothing forces the order and nothing connects the schema to the handler’s types.

If you’re new to Middy, check out our introduction to AWS Lambda middleware with Middy for fundamental concepts and patterns.

Here’s what I typically see in Lambda codebases:

// Easy to make mistakes - no compile-time checking
export const handler = middy(businessLogic)
  .use(httpErrorHandler()) // Should this be first or last?
  .use(validator({ eventSchema })) // No validation that schema matches event type
  .use(httpJsonBodyParser())
  .use(httpCors())
  // Forgot authentication middleware!

Common Issues:

  • No enforcement of middleware ordering (error handlers in wrong position)
  • Type safety breaks between validation and handler (schema doesn’t match handler types)
  • Inconsistent patterns across functions (some have auth, some don’t)
  • Cryptic JSON Schema validation errors
  • Repeated code for feature flags and secrets

Technical Requirements

To address these challenges, here’s what an enterprise middleware system needs:

  1. Compile-time type safety: Catch configuration errors before deployment
  2. Enforced middleware ordering: Consistent execution across all functions
  3. Better validation errors: Clear, actionable messages from schema validation
  4. Feature flag integration: Toggle features without code deployments
  5. Secrets management: Cached, rotation-aware secret access
  6. Discoverable API: Autocomplete and type hints guide developers
  7. Testability: Easy to mock and test middleware chains

Runtime Recommendation: Use Node.js 22.x for Lambda functions. Node.js 16 is already deprecated, Node.js 18 reached full deprecation on March 9, 2026, and Node.js 20 reaches end-of-life on April 30, 2026. For comprehensive TypeScript patterns and best practices in serverless applications, see our AWS Serverless with TypeScript guide.

Implementation: Type-Safe Builder Pattern

The builder pattern provides compile-time guarantees about middleware composition. Each builder method returns a new type with enriched context, ensuring TypeScript knows exactly what’s available in your handler.

Core Builder Implementation

interface MiddlewareConfig {
  enableAuth: boolean
  enableCors: boolean
  validationSchema?: z.ZodSchema
  featureFlags?: string[]
  secrets?: string[]
}

class LambdaMiddlewareBuilder<TEvent, TContext = {}> {
  private config: Partial<MiddlewareConfig> = {}

  withAuthentication(): LambdaMiddlewareBuilder<TEvent, TContext & { userId: string }> {
    this.config.enableAuth = true
    return this as any
  }

  withValidation<TSchema extends z.ZodSchema>(
    schema: TSchema
  ): LambdaMiddlewareBuilder<z.infer<TSchema>, TContext> {
    this.config.validationSchema = schema
    return this as any
  }

  withFeatureFlags(
    flags: string[]
  ): LambdaMiddlewareBuilder<TEvent, TContext & { features: Record<string, boolean> }> {
    this.config.featureFlags = flags
    return this as any
  }

  withSecrets(
    secrets: string[]
  ): LambdaMiddlewareBuilder<TEvent, TContext & { secrets: Record<string, string> }> {
    this.config.secrets = secrets
    return this as any
  }

  // Note: This implementation uses `as any` for simplicity. Production implementations
  // might use more sophisticated TypeScript techniques like mapped types or conditional
  // types to maintain full type safety without type assertions.

  build(handler: (event: TEvent, context: TContext) => Promise<any>) {
    const middlewareChain = middy(handler)

    // Enforce consistent ordering
    if (this.config.enableCors) {
      middlewareChain.use(httpCors())
    }

    middlewareChain.use(httpJsonBodyParser())

    if (this.config.validationSchema) {
      middlewareChain.use(zodValidationMiddleware(this.config.validationSchema))
    }

    if (this.config.enableAuth) {
      middlewareChain.use(authenticationMiddleware())
    }

    if (this.config.featureFlags) {
      middlewareChain.use(featureFlagsMiddleware(this.config.featureFlags))
    }

    if (this.config.secrets) {
      middlewareChain.use(secretsMiddleware(this.config.secrets))
    }

    middlewareChain.use(httpErrorHandler())

    return middlewareChain
  }
}

Usage with Full Type Safety

const requestSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  tenantId: z.string().uuid()
})

export const handler = new LambdaMiddlewareBuilder()
  .withAuthentication()
  .withValidation(requestSchema)
  .withFeatureFlags(['newLoginFlow', 'mfaEnabled'])
  .withSecrets(['DATABASE_URL', 'JWT_SECRET'])
  .build(async (event, context) => {
    // TypeScript knows:
    // - event matches requestSchema (email, password, tenantId)
    // - context has userId (from auth)
    // - context has features object
    // - context has secrets object

    if (context.features.mfaEnabled) {
      // Handle MFA flow
    }

    const dbUrl = context.secrets.DATABASE_URL
    // Business logic with full type safety
  })

Key Benefits:

  • Compile-time checking of context types
  • Enforced middleware ordering
  • Discoverable API through autocomplete
  • Single source of truth for middleware configuration

withAuthentication

withValidation

withFeatureFlags

withSecrets

build

LambdaMiddlewareBuilder

Builder + Auth Context

Builder + Validated Event Type

Builder + Features Context

Builder + Secrets Context

Typed Handler Function

Zod Validation Middleware

@middy/validator uses JSON Schema, which lacks TypeScript integration and provides cryptic error messages. Zod solves both problems elegantly.

For a comprehensive guide on using Zod with Lambda and OpenAPI integration, see our Zod + OpenAPI + AWS Lambda guide.

Custom Zod Middleware

import { z } from 'zod'
import createHttpError from 'http-errors'

const zodValidationMiddleware = <T extends z.ZodSchema>(schema: T) => {
  return {
    before: async (request: middy.Request) => {
      const body = request.event.body

      const result = schema.safeParse(body)

      if (!result.success) {
        // Transform Zod errors into user-friendly messages
        const errors = result.error.errors.map(err => ({
          field: err.path.join('.'),
          message: err.message,
          code: err.code
        }))

        throw createHttpError(400, 'Validation failed', { errors })
      }

      // Replace event.body with validated, typed data
      request.event.body = result.data
    }
  }
}

Rich Error Messages

const userSchema = z.object({
  email: z.string().email('Please provide a valid email address'),
  age: z.number().int().min(18, 'You must be at least 18 years old'),
  phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number format'),
  acceptedTerms: z.boolean().refine(val => val === true, {
    message: 'You must accept the terms and conditions'
  })
})

// Example error response:
// {
//  "statusCode": 400,
//  "message": "Validation failed",
//  "errors": [
//  {
//  "field": "email",
//  "message": "Please provide a valid email address",
//  "code": "invalid_string"
//  },
//  {
//  "field": "age",
//  "message": "You must be at least 18 years old",
//  "code": "too_small"
//  }
//  ]
// }

Advanced Validation Patterns

Zod excels at complex validation scenarios:

// Cross-field validation
const orderSchema = z.object({
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().positive()
  })).min(1, 'Order must contain at least one item'),
  total: z.number().positive()
}).refine(data => {
  // Verify total matches sum of items
  const calculatedTotal = data.items.reduce((sum, item) =>
    sum + (item.quantity * getPriceForProduct(item.productId)), 0
  )
  return Math.abs(calculatedTotal - data.total) < 0.01
}, {
  message: 'Order total does not match item prices',
  path: ['total']
})

// Discriminated unions for polymorphic inputs
const notificationSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('email'),
    recipient: z.string().email(),
    subject: z.string(),
    body: z.string()
  }),
  z.object({
    type: z.literal('sms'),
    phoneNumber: z.string(),
    message: z.string().max(160)
  }),
  z.object({
    type: z.literal('push'),
    deviceToken: z.string(),
    title: z.string(),
    body: z.string()
  })
])

The discriminated union provides type narrowing based on the type field, giving you full type safety for each variant.

Feature Flags Middleware

Feature flags enable dynamic behavior changes without redeploying code. AWS AppConfig provides enterprise-grade feature flag management with proper caching.

Implementation with AppConfig

import axios from 'axios'

interface FeatureFlagsContext {
  features: Record<string, boolean>
}

const featureFlagsMiddleware = (flagNames: string[]) => {
  // Cache configuration at Lambda container level
  let cachedFlags: Record<string, boolean> | null = null
  let lastFetchTime = 0
  const CACHE_TTL_MS = 30000 // 30 seconds

  return {
    before: async (request: middy.Request<any, FeatureFlagsContext>) => {
      const now = Date.now()

      // Use cached flags if still fresh
      if (cachedFlags && (now - lastFetchTime) < CACHE_TTL_MS) {
        request.context.features = cachedFlags
        return
      }

      try {
        // Fetch from AppConfig Lambda Extension (localhost endpoint)
        const response = await axios.get(
          `http://localhost:2772/applications/${process.env.APPCONFIG_APP}/environments/${process.env.APPCONFIG_ENV}/configurations/${process.env.APPCONFIG_CONFIG}`,
          { timeout: 3000 }
        )

        const allFlags = response.data

        // Extract only requested flags
        const features: Record<string, boolean> = {}
        flagNames.forEach(name => {
          features[name] = allFlags[name] ?? false
        })

        cachedFlags = features
        lastFetchTime = now
        request.context.features = features
      } catch (error) {
        console.error('Failed to fetch feature flags:', error)
        // Fail open with all flags disabled
        request.context.features = Object.fromEntries(
          flagNames.map(name => [name, false])
        )
      }
    }
  }
}

Advanced Pattern: User-Specific Flags

For more sophisticated scenarios, you can implement percentage rollouts and user targeting:

interface FeatureFlagConfig {
  enabled: boolean
  rolloutPercentage?: number
  targetUserIds?: string[]
  targetTenants?: string[]
}

const advancedFeatureFlagsMiddleware = (flagNames: string[]) => {
  return {
    before: async (request: middy.Request) => {
      const allFlags = await fetchFlags()
      const userId = request.context.userId // From auth middleware
      const tenantId = request.event.body?.tenantId

      const features: Record<string, boolean> = {}

      for (const flagName of flagNames) {
        const config: FeatureFlagConfig = allFlags[flagName]

        if (!config?.enabled) {
          features[flagName] = false
          continue
        }

        // Check user targeting
        if (config.targetUserIds?.includes(userId)) {
          features[flagName] = true
          continue
        }

        // Check tenant targeting
        if (config.targetTenants?.includes(tenantId)) {
          features[flagName] = true
          continue
        }

        // Check percentage rollout
        if (config.rolloutPercentage) {
          const hash = hashString(`${flagName}:${userId}`)
          const userPercentage = (hash % 100) + 1
          features[flagName] = userPercentage <= config.rolloutPercentage
          continue
        }

        features[flagName] = config.enabled
      }

      request.context.features = features
    }
  }
}

Lambda Extension Setup

Configure the AppConfig Lambda Extension in your serverless configuration:

# serverless.yml or SAM template
provider:
  environment:
    AWS_APPCONFIG_EXTENSION_POLL_INTERVAL_SECONDS: 30
    AWS_APPCONFIG_EXTENSION_POLL_TIMEOUT_MILLIS: 3000
    APPCONFIG_APP: MyApplication
    APPCONFIG_ENV: ${opt:stage}
    APPCONFIG_CONFIG: feature-flags

  iamRoleStatements:
    - Effect: Allow
      Action:
        - appconfig:GetConfiguration
        - appconfig:GetLatestConfiguration
        - appconfig:StartConfigurationSession
      Resource: '*'

functions:
  api:
    handler: handler.main
    layers:
      # AppConfig Lambda Extension (region-specific ARN)
      # Check https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions-versions.html
      # for the latest version in your region
      - arn:aws:lambda:us-east-1:027255383542:layer:AWS-AppConfig-Extension:207

Secrets Management Middleware

AWS Secrets Manager integration needs proper caching and rotation handling to avoid API throttling and support zero-downtime rotation.

Basic Secrets Middleware

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'

interface SecretsContext {
  secrets: Record<string, string>
}

const secretsMiddleware = (secretNames: string[]) => {
  // Lambda container-level cache
  const secretCache = new Map<string, { value: string; fetchedAt: number }>()
  const CACHE_TTL_MS = 300000 // 5 minutes

  // Reuse client across invocations
  const client = new SecretsManagerClient({ region: process.env.AWS_REGION })

  return {
    before: async (request: middy.Request<any, SecretsContext>) => {
      const secrets: Record<string, string> = {}
      const now = Date.now()

      // Fetch secrets in parallel
      await Promise.all(
        secretNames.map(async (secretName) => {
          // Check cache first
          const cached = secretCache.get(secretName)
          if (cached && (now - cached.fetchedAt) < CACHE_TTL_MS) {
            secrets[secretName] = cached.value
            return
          }

          try {
            const command = new GetSecretValueCommand({ SecretId: secretName })
            const response = await client.send(command)
            const secretValue = response.SecretString || ''

            secrets[secretName] = secretValue
            secretCache.set(secretName, { value: secretValue, fetchedAt: now })
          } catch (error) {
            console.error(`Failed to fetch secret ${secretName}:`, error)
            // Use cached value even if stale, or fail
            const cached = secretCache.get(secretName)
            if (cached) {
              console.warn(`Using stale cached secret ${secretName}`)
              secrets[secretName] = cached.value
            } else {
              throw new Error(`Required secret ${secretName} not available`)
            }
          }
        })
      )

      request.context.secrets = secrets
    }
  }
}

Structured Secrets with Parsing

Many secrets are JSON objects. Add parsing support to maintain type safety:

interface DatabaseConfig {
  host: string
  port: number
  username: string
  password: string
  database: string
}

const secretsWithParsingMiddleware = (secretConfigs: Array<{
  name: string
  parser?: (raw: string) => any
}>) => {
  return {
    before: async (request: middy.Request) => {
      const rawSecrets = await fetchSecrets(secretConfigs.map(c => c.name))

      const secrets: Record<string, any> = {}

      for (const config of secretConfigs) {
        const rawValue = rawSecrets[config.name]
        secrets[config.name] = config.parser
          ? config.parser(rawValue)
          : rawValue
      }

      request.context.secrets = secrets
    }
  }
}

// Usage
export const handler = new LambdaMiddlewareBuilder()
  .withSecrets([
    {
      name: 'prod/database/credentials',
      parser: (raw) => JSON.parse(raw) as DatabaseConfig
    },
    {
      name: 'prod/api/keys',
      parser: (raw) => JSON.parse(raw)
    }
  ])
  .build(async (event, context) => {
    const dbConfig = context.secrets['prod/database/credentials'] as DatabaseConfig
    const connection = await createConnection({
      host: dbConfig.host,
      port: dbConfig.port,
      // TypeScript knows the structure!
    })
  })

Complete Example: Order Endpoint

Let’s combine everything in an e-commerce API endpoint:

// schemas/order.schema.ts
import { z } from 'zod'

export const createOrderSchema = z.object({
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().positive(),
    price: z.number().positive()
  })).min(1),
  shippingAddress: z.object({
    street: z.string().min(1),
    city: z.string().min(1),
    postalCode: z.string(),
    country: z.string().length(2)
  }),
  paymentMethodId: z.string()
})

// handlers/orders.ts
export const createOrder = new LambdaMiddlewareBuilder()
  .withCors()
  .withAuthentication()
  .withValidation(createOrderSchema)
  .withFeatureFlags(['expressFulfillment', 'fraudDetection', 'loyaltyProgram'])
  .withSecrets(['database-credentials', 'payment-api-key'])
  .build(async (event, context) => {
    // TypeScript knows all these types!
    const { items, shippingAddress, paymentMethodId } = event.body
    const { userId } = context
    const { expressFulfillment, fraudDetection, loyaltyProgram } = context.features
    const dbCreds = JSON.parse(context.secrets['database-credentials'])
    const paymentKey = context.secrets['payment-api-key']

    // Apply fraud detection if enabled
    if (fraudDetection) {
      const riskScore = await checkFraudRisk(userId, items, shippingAddress)
      if (riskScore > 0.8) {
        return {
          statusCode: 400,
          body: JSON.stringify({ error: 'Order flagged for manual review' })
        }
      }
    }

    // Calculate loyalty points if enabled
    let loyaltyPoints = 0
    if (loyaltyProgram) {
      loyaltyPoints = calculateLoyaltyPoints(items)
    }

    // Create order with express fulfillment option
    const order = await createOrderInDatabase(dbCreds, {
      userId,
      items,
      shippingAddress,
      paymentMethodId,
      expressDelivery: expressFulfillment,
      loyaltyPoints
    })

    // Process payment
    await processPayment(paymentKey, {
      amount: order.total,
      paymentMethodId
    })

    return {
      statusCode: 201,
      body: JSON.stringify({
        orderId: order.id,
        estimatedDelivery: expressFulfillment
          ? addDays(new Date(), 1)
          : addDays(new Date(), 5),
        loyaltyPointsEarned: loyaltyPoints
      })
    }
  })

The endpoint above ties the pieces together:

  • Type-safe validation with Zod
  • Dynamic feature flags for gradual rollouts
  • Secure secrets management
  • Full TypeScript type inference throughout
DatabaseSecretsManagerAppConfigMiddlewareLambdaClientDatabaseSecretsManagerAppConfigMiddlewareLambdaClientPOST /ordersProcess requestCORS & Parse JSONValidate with ZodAuthenticate userFetch feature flags (cached)Return flagsFetch secrets (cached)Return credentialsExecute handlerCreate orderOrder created201 Created

Testing Strategies

The builder pattern makes testing significantly easier through composition and injection.

Mocking Middleware Context

// tests/orders.test.ts
import { createOrder } from '../handlers/orders'

describe('Create Order Handler', () => {
  it('should create order with express fulfillment when flag enabled', async () => {
    const mockEvent = {
      body: {
        items: [{ productId: '123', quantity: 2, price: 29.99 }],
        shippingAddress: {
          street: '123 Main St',
          city: 'Seattle',
          postalCode: '98101',
          country: 'US'
        },
        paymentMethodId: 'pm_123'
      }
    }

    const mockContext = {
      userId: 'user-123',
      features: {
        expressFulfillment: true,
        fraudDetection: false,
        loyaltyProgram: true
      },
      secrets: {
        'database-credentials': JSON.stringify({
          host: 'localhost',
          port: 5432,
          username: 'test',
          password: 'test'
        }),
        'payment-api-key': 'test-key'
      }
    }

    const response = await createOrder.handler(mockEvent, mockContext)

    expect(response.statusCode).toBe(201)
    const body = JSON.parse(response.body)
    expect(body.loyaltyPointsEarned).toBeGreaterThan(0)
  })
})

Test Builder Pattern

Create a test helper that mirrors the builder pattern:

class TestMiddlewareBuilder {
  private features: Record<string, boolean> = {}
  private secrets: Record<string, string> = {}
  private userId = 'test-user'

  withFeature(name: string, enabled: boolean): this {
    this.features[name] = enabled
    return this
  }

  withSecret(name: string, value: string): this {
    this.secrets[name] = value
    return this
  }

  withUserId(id: string): this {
    this.userId = id
    return this
  }

  buildContext() {
    return {
      userId: this.userId,
      features: this.features,
      secrets: this.secrets
    }
  }
}

// Usage in tests
const context = new TestMiddlewareBuilder()
  .withFeature('expressFulfillment', true)
  .withFeature('fraudDetection', false)
  .withSecret('database-credentials', '{"host":"localhost"}')
  .withUserId('test-123')
  .buildContext()

This approach provides the same fluent API for test setup, making tests readable and maintainable.

Performance Considerations

Understanding the performance implications helps you make informed trade-offs.

Cold Start Impact

Every middleware in the chain is module code that loads and initializes before the first invocation can return. In this stack the contributors are Zod schema construction, the AWS SDK v3 clients you instantiate, and the AppConfig extension’s own startup. The builder itself costs nothing at runtime; it only calls .use() in a fixed sequence.

Treat that overhead as a one-time container initialization cost and measure it against your own bundle instead of a generic figure, because package size and the number of SDK clients dominate the result. For detailed cold start optimization strategies, see our AWS Lambda Cold Start Optimization guide.

Memory Usage

Middy’s core is small. The memory that matters comes from Zod and the AWS SDK v3 clients you import, plus any extension running beside the function. Read the Max Memory Used value in the Lambda REPORT line after adding middleware and size the function from that, since the 128 MB floor leaves less headroom than it looks like once a few SDK clients are resident.

Optimization Strategies

1. Connection Reuse

// Keep AWS SDK clients at module scope
const secretsClient = new SecretsManagerClient({ region: process.env.AWS_REGION })

const secretsMiddleware = (names: string[]) => {
  return {
    before: async (request) => {
      // Reuse client across invocations
      const secrets = await fetchSecretsWithClient(secretsClient, names)
      request.context.secrets = secrets
    }
  }
}

2. Selective Middleware Only include middleware you need:

// Lightweight public endpoint
const publicHandler = new LambdaMiddlewareBuilder()
  .withCors()
  .withValidation(schema)
  .build(handler)

// Full-featured authenticated endpoint
const privateHandler = new LambdaMiddlewareBuilder()
  .withCors()
  .withAuthentication()
  .withValidation(schema)
  .withFeatureFlags(['feature1', 'feature2'])
  .withSecrets(['secret1'])
  .build(handler)

3. Cache Warming Pre-fetch during container initialization:

// module-level initialization
let warmCache: Promise<void> | null = null

if (!warmCache) {
  warmCache = (async () => {
    await Promise.all([
      prefetchFeatureFlags(),
      prefetchSecrets()
    ])
  })()
}

Cost Analysis

The two managed services in this chain bill separately from Lambda. The rates below are AWS list prices (linked in the references); the request counts follow from the cache settings used earlier in the code, so recalculate them if you change a TTL.

AWS Service Costs

AppConfig (Feature Flags):

  • API requests: $0.20 per 1M requests
  • Configurations received: $0.0008 per configuration ($800 per 1M)
  • A container polling every 30 seconds makes about 2,880 requests per day while it stays warm
  • Ten continuously warm functions: roughly $0.17/month in API requests
  • The per-configuration charge applies when the configuration actually changes, not on every poll, so it stays near zero for flags that flip a few times a week
  • Assessment: Negligible next to the operational flexibility, as long as nobody shortens the poll interval to seconds

Secrets Manager:

  • Secret storage: $0.40/month per secret
  • API requests: $0.05 per 10,000 requests
  • With 5-minute caching: ~288 requests/day/function
  • Five secrets across ten functions: $2.00/month storage plus roughly $0.45 in requests
  • Trade-off: Higher cost than Parameter Store, but supports automatic rotation

Lambda Extension Overhead:

  • Extensions run in the same execution environment and consume part of the function’s memory allocation
  • Minimal impact on execution cost
  • Reduces external API calls significantly

Common Pitfalls and Solutions

Here are lessons from implementations that went sideways.

1. Feature Flag Cache Staleness

Problem: Lambda containers can live for hours, using stale feature flag values.

Solution: Implement TTL-based cache refresh with emergency override:

const CACHE_TTL = process.env.FEATURE_FLAG_TTL
  ? parseInt(process.env.FEATURE_FLAG_TTL)
  : 30000 // 30 seconds default

// Provide emergency override
if (process.env.BYPASS_FLAG_CACHE === 'true') {
  // Always fetch fresh flags (for critical updates)
}

2. Secret Rotation Timing

Problem: Secrets Manager rotates secrets, but cached values in Lambda cause auth failures.

Solution: Implement rotation-aware caching with retry logic:

const secretsMiddleware = () => {
  return {
    before: async (request) => {
      try {
        request.context.secrets = await fetchSecrets()
      } catch (error) {
        if (isAuthError(error)) {
          // Clear cache and retry once
          clearSecretCache()
          request.context.secrets = await fetchSecrets()
        } else {
          throw error
        }
      }
    }
  }
}

3. Middleware Ordering Issues

Problem: Error handler needs to be last, but builder pattern makes it easy to add middleware in wrong order.

Solution: Builder enforces ordering internally:

class SafeBuilder {
  build(handler: any) {
    const chain = middy(handler)

    // Core middleware in specific order
    chain.use(httpJsonBodyParser())  // 1. Parse body
    // ... validation, auth, etc
    chain.use(httpErrorHandler())  // Last: Handle errors

    return chain
  }
}

Alternative Approaches

It’s worth understanding alternatives to make informed decisions.

For scenarios where you need even more control over middleware execution or face specific performance requirements, consider reading about building custom middleware frameworks that go beyond Middy’s capabilities.

vs. AWS Lambda Powertools

AWS Lambda Powertools:

import { Logger, Tracer, Metrics } from '@aws-lambda-powertools/logger'
import { parser } from '@aws-lambda-powertools/parser'

@parser({ schema: mySchema })
export const handler = async (event, context) => {
  logger.info('Processing request', { event })
}

Comparison:

  • Powertools: Better observability, AWS-maintained, comprehensive features
  • Custom Builder: More flexibility with middleware composition, smaller bundle
  • Recommendation: Combine both - use Powertools for logging/tracing, custom builder for business middleware

vs. Pure Functional Middleware

Functional Approach:

type Middleware<T> = (next: Handler<T>) => Handler<T>

const compose = <T>(...middlewares: Middleware<T>[]) =>
  (handler: Handler<T>) =>
    middlewares.reduceRight((next, middleware) => middleware(next), handler)

export const handler = compose(
  withAuth,
  withValidation(schema),
  withFeatureFlags(['flag1'])
)(businessLogic)

Trade-off: Functional composition is elegant but provides less TypeScript support for context enrichment. Choose based on team preference.

When the Builder Pays Off

The builder earns its keep once a codebase has enough handlers that middleware order and context types no longer fit in one person’s head, and it pays off most where several functions share the same authentication, validation, and secrets wiring. Keep the ordering rules inside build() so no call site can get them wrong, cache flags and secrets with an explicit TTL, and define fallback behaviour for both, because a configuration service having a bad day should not take an endpoint down with it.

Skip the abstraction for a single-purpose function, a scheduled job, or anything without an HTTP surface: there a plain middy() chain is shorter than the wrapper around it. Skip it too if the team already runs Lambda Powertools end to end and would rather keep one vendor-maintained toolchain than maintain a local one. A reasonable middle path is to start with a builder that covers only validation and error handling, then fold in feature flags and secrets when the second or third handler asks for the same wiring.

References

Related posts