Serverless Architecture Patterns: Building Scalable Applications Without Servers

Serverless architecture has matured significantly, offering developers powerful ways to build scalable applications without managing infrastructure. After implementing numerous serverless solutions, I want to share the patterns and practices that lead to successful deployments.

Core Serverless Principles

Function as a Service (FaaS)

// AWS Lambda function example
exports.handler = async (event, context) => {
  const { httpMethod, path, body } = event
  
  try {
    switch (httpMethod) {
      case 'GET':
        return await handleGet(path)
      case 'POST':
        return await handlePost(JSON.parse(body))
      default:
        return {
          statusCode: 405,
          body: JSON.stringify({ error: 'Method not allowed' })
        }
    }
  } catch (error) {
    console.error('Function error:', error)
    return {
      statusCode: 500,
      body: JSON.stringify({ error: 'Internal server error' })
    }
  }
}

async function handleGet(path) {
  // Business logic here
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: 'Success' })
  }
}

Event-Driven Architecture

// Event processing with multiple triggers
const AWS = require('aws-sdk')
const dynamodb = new AWS.DynamoDB.DocumentClient()

// S3 trigger for image processing
exports.processImage = async (event) => {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name
    const key = record.s3.object.key
    
    // Process image
    const processedImage = await resizeImage(bucket, key)
    
    // Store metadata
    await dynamodb.put({
      TableName: 'ProcessedImages',
      Item: {
        id: key,
        bucket,
        processedAt: new Date().toISOString(),
        size: processedImage.size
      }
    }).promise()
    
    // Trigger next step
    await publishEvent('image.processed', {
      bucket,
      key,
      processedUrl: processedImage.url
    })
  }
}

// DynamoDB trigger for notifications
exports.sendNotification = async (event) => {
  for (const record of event.Records) {
    if (record.eventName === 'INSERT') {
      const newImage = record.dynamodb.NewImage
      await sendEmail({
        to: newImage.userEmail.S,
        subject: 'Image processed successfully',
        body: `Your image ${newImage.id.S} has been processed`
      })
    }
  }
}

Serverless Patterns

API Gateway Pattern

// Serverless Express.js with API Gateway
const serverless = require('serverless-http')
const express = require('express')
const cors = require('cors')

const app = express()

app.use(cors())
app.use(express.json())

// Middleware for request validation
const validateRequest = (schema) => (req, res, next) => {
  const { error } = schema.validate(req.body)
  if (error) {
    return res.status(400).json({ error: error.details[0].message })
  }
  next()
}

// Routes
app.get('/users/:id', async (req, res) => {
  try {
    const user = await getUserById(req.params.id)
    res.json(user)
  } catch (error) {
    res.status(404).json({ error: 'User not found' })
  }
})

app.post('/users', validateRequest(userSchema), async (req, res) => {
  try {
    const user = await createUser(req.body)
    res.status(201).json(user)
  } catch (error) {
    res.status(500).json({ error: 'Failed to create user' })
  }
})

module.exports.handler = serverless(app)

Queue Processing Pattern

// SQS message processing
exports.processQueue = async (event) => {
  const results = await Promise.allSettled(
    event.Records.map(async (record) => {
      const message = JSON.parse(record.body)
      
      try {
        await processMessage(message)
        
        // Delete message from queue on success
        await sqs.deleteMessage({
          QueueUrl: process.env.QUEUE_URL,
          ReceiptHandle: record.receiptHandle
        }).promise()
        
        return { success: true, messageId: record.messageId }
      } catch (error) {
        console.error('Processing failed:', error)
        
        // Send to DLQ after max retries
        if (record.attributes.ApproximateReceiveCount > 3) {
          await sendToDeadLetterQueue(message, error)
        }
        
        throw error
      }
    })
  )
  
  const failed = results.filter(r => r.status === 'rejected')
  if (failed.length > 0) {
    console.error(`${failed.length} messages failed processing`)
  }
}

async function processMessage(message) {
  switch (message.type) {
    case 'user.created':
      await sendWelcomeEmail(message.data)
      break
    case 'order.placed':
      await processOrder(message.data)
      break
    default:
      throw new Error(`Unknown message type: ${message.type}`)
  }
}

CRON Job Pattern

// Scheduled functions with EventBridge
exports.dailyCleanup = async (event) => {
  console.log('Starting daily cleanup...')
  
  const tasks = [
    cleanupExpiredSessions(),
    archiveOldLogs(),
    generateDailyReports(),
    optimizeDatabase()
  ]
  
  const results = await Promise.allSettled(tasks)
  
  const summary = {
    timestamp: new Date().toISOString(),
    tasks: results.map((result, index) => ({
      task: ['sessions', 'logs', 'reports', 'database'][index],
      status: result.status,
      error: result.status === 'rejected' ? result.reason.message : null
    }))
  }
  
  // Store cleanup summary
  await dynamodb.put({
    TableName: 'CleanupLogs',
    Item: summary
  }).promise()
  
  console.log('Cleanup completed:', summary)
}

async function cleanupExpiredSessions() {
  const expiredSessions = await dynamodb.scan({
    TableName: 'UserSessions',
    FilterExpression: 'expiresAt < :now',
    ExpressionAttributeValues: {
      ':now': Date.now()
    }
  }).promise()
  
  if (expiredSessions.Items.length > 0) {
    const deleteRequests = expiredSessions.Items.map(item => ({
      DeleteRequest: { Key: { sessionId: item.sessionId } }
    }))
    
    await dynamodb.batchWrite({
      RequestItems: {
        UserSessions: deleteRequests
      }
    }).promise()
  }
  
  return { deleted: expiredSessions.Items.length }
}

Edge Computing with Serverless

Cloudflare Workers

// Edge function for global performance
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const url = new URL(request.url)
  
  // Edge caching
  const cache = caches.default
  const cacheKey = new Request(url.toString(), request)
  let response = await cache.match(cacheKey)
  
  if (!response) {
    // Geolocation-based routing
    const country = request.cf.country
    const region = getRegion(country)
    
    // Route to nearest origin
    const originUrl = getOriginForRegion(region)
    
    response = await fetch(`${originUrl}${url.pathname}`, {
      method: request.method,
      headers: request.headers,
      body: request.body
    })
    
    // Cache successful responses
    if (response.status === 200) {
      response = new Response(response.body, {
        status: response.status,
        statusText: response.statusText,
        headers: {
          ...response.headers,
          'Cache-Control': 'public, max-age=3600',
          'X-Edge-Cache': 'MISS',
          'X-Region': region
        }
      })
      
      event.waitUntil(cache.put(cacheKey, response.clone()))
    }
  } else {
    response = new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: {
        ...response.headers,
        'X-Edge-Cache': 'HIT'
      }
    })
  }
  
  return response
}

function getRegion(country) {
  const regions = {
    'US': 'us-east-1',
    'CA': 'us-east-1',
    'GB': 'eu-west-1',
    'DE': 'eu-west-1',
    'JP': 'ap-northeast-1',
    'AU': 'ap-southeast-2'
  }
  
  return regions[country] || 'us-east-1'
}

Vercel Edge Functions

// Next.js Edge API Route
import { NextRequest, NextResponse } from 'next/server'

export const config = {
  runtime: 'edge'
}

export default async function handler(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const userId = searchParams.get('userId')
  
  if (!userId) {
    return NextResponse.json(
      { error: 'userId is required' },
      { status: 400 }
    )
  }
  
  try {
    // Edge KV storage
    const userData = await env.USER_CACHE.get(userId)
    
    if (userData) {
      return NextResponse.json(
        JSON.parse(userData),
        {
          headers: {
            'Cache-Control': 'public, max-age=300',
            'X-Cache': 'HIT'
          }
        }
      )
    }
    
    // Fetch from origin if not in cache
    const response = await fetch(`${env.API_BASE_URL}/users/${userId}`)
    const user = await response.json()
    
    // Cache for 5 minutes
    await env.USER_CACHE.put(userId, JSON.stringify(user), {
      expirationTtl: 300
    })
    
    return NextResponse.json(user, {
      headers: {
        'Cache-Control': 'public, max-age=300',
        'X-Cache': 'MISS'
      }
    })
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch user' },
      { status: 500 }
    )
  }
}

Data Management in Serverless

DynamoDB Patterns

// Single Table Design
const createUser = async (userData) => {
  const userId = generateId()
  
  const items = [
    {
      PK: `USER#${userId}`,
      SK: `PROFILE`,
      Type: 'User',
      ...userData,
      createdAt: new Date().toISOString()
    },
    {
      PK: `USER#${userId}`,
      SK: `SETTINGS`,
      Type: 'UserSettings',
      theme: 'light',
      notifications: true
    }
  ]
  
  await dynamodb.transactWrite({
    TransactItems: items.map(item => ({
      Put: {
        TableName: 'AppData',
        Item: item,
        ConditionExpression: 'attribute_not_exists(PK)'
      }
    }))
  }).promise()
  
  return { userId, ...userData }
}

// Query patterns
const getUserWithPosts = async (userId) => {
  const result = await dynamodb.query({
    TableName: 'AppData',
    KeyConditionExpression: 'PK = :pk',
    ExpressionAttributeValues: {
      ':pk': `USER#${userId}`
    }
  }).promise()
  
  const user = result.Items.find(item => item.SK === 'PROFILE')
  const posts = result.Items.filter(item => item.SK.startsWith('POST#'))
  
  return { user, posts }
}

Connection Pooling for RDS

// RDS Proxy for connection management
const mysql = require('mysql2/promise')

let pool

const getConnection = () => {
  if (!pool) {
    pool = mysql.createPool({
      host: process.env.RDS_PROXY_ENDPOINT,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
      connectionLimit: 1, // Lambda reuses connections
      acquireTimeout: 60000,
      timeout: 60000
    })
  }
  return pool
}

exports.handler = async (event) => {
  const connection = getConnection()
  
  try {
    const [rows] = await connection.execute(
      'SELECT * FROM users WHERE id = ?',
      [event.userId]
    )
    
    return {
      statusCode: 200,
      body: JSON.stringify(rows[0])
    }
  } catch (error) {
    console.error('Database error:', error)
    return {
      statusCode: 500,
      body: JSON.stringify({ error: 'Database error' })
    }
  }
}

Monitoring and Observability

Distributed Tracing

const AWSXRay = require('aws-xray-sdk-core')
const AWS = AWSXRay.captureAWS(require('aws-sdk'))

exports.handler = async (event, context) => {
  const segment = AWSXRay.getSegment()
  
  // Create subsegment for business logic
  const subsegment = segment.addNewSubsegment('process-order')
  
  try {
    subsegment.addAnnotation('orderId', event.orderId)
    subsegment.addMetadata('orderData', event.orderData)
    
    const result = await processOrder(event.orderData)
    
    subsegment.addMetadata('result', result)
    subsegment.close()
    
    return {
      statusCode: 200,
      body: JSON.stringify(result)
    }
  } catch (error) {
    subsegment.addError(error)
    subsegment.close(error)
    throw error
  }
}

async function processOrder(orderData) {
  // Trace external API calls
  const segment = AWSXRay.getSegment()
  const subsegment = segment.addNewSubsegment('payment-api')
  
  try {
    const paymentResult = await fetch('/api/payment', {
      method: 'POST',
      body: JSON.stringify(orderData)
    })
    
    subsegment.addMetadata('paymentResponse', paymentResult)
    subsegment.close()
    
    return paymentResult
  } catch (error) {
    subsegment.addError(error)
    subsegment.close(error)
    throw error
  }
}

Custom Metrics

const AWS = require('aws-sdk')
const cloudwatch = new AWS.CloudWatch()

const publishMetric = async (metricName, value, unit = 'Count') => {
  await cloudwatch.putMetricData({
    Namespace: 'MyApp/Functions',
    MetricData: [{
      MetricName: metricName,
      Value: value,
      Unit: unit,
      Timestamp: new Date(),
      Dimensions: [{
        Name: 'FunctionName',
        Value: process.env.AWS_LAMBDA_FUNCTION_NAME
      }]
    }]
  }).promise()
}

exports.handler = async (event) => {
  const startTime = Date.now()
  
  try {
    const result = await processRequest(event)
    
    // Success metrics
    await publishMetric('RequestsSuccessful', 1)
    await publishMetric('ProcessingTime', Date.now() - startTime, 'Milliseconds')
    
    return result
  } catch (error) {
    // Error metrics
    await publishMetric('RequestsFailed', 1)
    await publishMetric('ErrorRate', 1, 'Percent')
    
    throw error
  }
}

Deployment and CI/CD

Serverless Framework

# serverless.yml
service: my-serverless-app

provider:
  name: aws
  runtime: nodejs18.x
  region: us-east-1
  environment:
    STAGE: ${self:provider.stage}
    TABLE_NAME: ${self:service}-${self:provider.stage}-data

functions:
  api:
    handler: src/api.handler
    events:
      - http:
          path: /{proxy+}
          method: ANY
          cors: true
    environment:
      DATABASE_URL: ${env:DATABASE_URL}
  
  processQueue:
    handler: src/queue.handler
    events:
      - sqs:
          arn: !GetAtt ProcessingQueue.Arn
          batchSize: 10
  
  scheduledTask:
    handler: src/scheduled.handler
    events:
      - schedule: rate(1 hour)

resources:
  Resources:
    ProcessingQueue:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: ${self:service}-${self:provider.stage}-processing
        VisibilityTimeoutSeconds: 300
        
    DataTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.environment.TABLE_NAME}
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: PK
            AttributeType: S
          - AttributeName: SK
            AttributeType: S
        KeySchema:
          - AttributeName: PK
            KeyType: HASH
          - AttributeName: SK
            KeyType: RANGE

plugins:
  - serverless-webpack
  - serverless-offline

GitHub Actions Deployment

name: Deploy Serverless Application

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run tests
        run: npm test
      
      - name: Run linting
        run: npm run lint

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Deploy to AWS
        run: npx serverless deploy --stage production
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

Cost Optimization

Function Optimization

// Optimize cold starts
const AWS = require('aws-sdk')

// Initialize outside handler for connection reuse
const dynamodb = new AWS.DynamoDB.DocumentClient()
const s3 = new AWS.S3()

// Provisioned concurrency for predictable performance
exports.handler = async (event, context) => {
  // Minimize initialization code
  const startTime = Date.now()
  
  try {
    const result = await processEvent(event)
    
    // Log performance metrics
    console.log(`Execution time: ${Date.now() - startTime}ms`)
    
    return result
  } catch (error) {
    console.error('Function error:', error)
    throw error
  }
}

// Use appropriate memory allocation
// Higher memory = faster CPU, but higher cost
// Profile your functions to find the sweet spot

Conclusion

Serverless architecture offers powerful benefits: automatic scaling, pay-per-use pricing, and reduced operational overhead. Success requires understanding the patterns, limitations, and best practices.

Key takeaways:

  • Design for statelessness and idempotency
  • Embrace event-driven architectures
  • Monitor performance and costs closely
  • Use appropriate data storage patterns
  • Implement proper error handling and retries
  • Optimize for cold start performance

Serverless isn't suitable for every use case, but when applied correctly, it enables rapid development and deployment of highly scalable applications with minimal infrastructure management.

Serverless Architecture Patterns: Building Scalable Applications Without Servers - Daniel Gurczynski