Published on

Serverless Architecture Patterns for Modern Applications

Serverless Architecture Patterns for Modern Applications

Serverless computing has revolutionized how we build and deploy applications. This guide explores proven serverless patterns, best practices, and real-world implementation strategies for creating scalable, cost-effective applications. ☁️

Core Serverless Patterns

1. API Gateway + Lambda Pattern

// AWS Lambda function
exports.handler = async (event, context) => {
  const { httpMethod, path, body, headers } = event

  try {
    // Route handling
    switch (`${httpMethod} ${path}`) {
      case 'GET /users':
        return await getUsers(event.queryStringParameters)
      case 'POST /users':
        return await createUser(JSON.parse(body))
      case 'GET /users/{id}':
        return await getUser(event.pathParameters.id)
      default:
        return {
          statusCode: 404,
          body: JSON.stringify({ message: 'Not Found' }),
        }
    }
  } catch (error) {
    console.error('Error:', error)
    return {
      statusCode: 500,
      body: JSON.stringify({ message: 'Internal Server Error' }),
    }
  }
}

const getUsers = async (queryParams = {}) => {
  const { limit = 10, offset = 0 } = queryParams

  // Database query logic here
  const users = await dynamodb
    .scan({
      TableName: 'Users',
      Limit: parseInt(limit),
      ExclusiveStartKey: offset ? { id: offset } : undefined,
    })
    .promise()

  return {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*',
    },
    body: JSON.stringify({
      users: users.Items,
      nextToken: users.LastEvaluatedKey?.id,
    }),
  }
}

2. Event-Driven Architecture

# serverless.yml
service: event-driven-app

provider:
  name: aws
  runtime: nodejs18.x

functions:
  userCreated:
    handler: handlers/userCreated.handler
    events:
      - sns:
          arn: arn:aws:sns:us-east-1:123456789:user-events
          filterPolicy:
            eventType:
              - user.created

  sendWelcomeEmail:
    handler: handlers/emailService.sendWelcome
    events:
      - sns:
          arn: arn:aws:sns:us-east-1:123456789:user-events
          filterPolicy:
            eventType:
              - user.created

  updateAnalytics:
    handler: handlers/analytics.updateUserStats
    events:
      - sns:
          arn: arn:aws:sns:us-east-1:123456789:user-events
// Event publisher
const AWS = require('aws-sdk')
const sns = new AWS.SNS()

const publishUserEvent = async (eventType, userData) => {
  const message = {
    eventType,
    timestamp: new Date().toISOString(),
    data: userData,
  }

  await sns
    .publish({
      TopicArn: process.env.USER_EVENTS_TOPIC,
      Message: JSON.stringify(message),
      MessageAttributes: {
        eventType: {
          DataType: 'String',
          StringValue: eventType,
        },
      },
    })
    .promise()
}

// Event handler
exports.handler = async (event) => {
  for (const record of event.Records) {
    const message = JSON.parse(record.Sns.Message)

    switch (message.eventType) {
      case 'user.created':
        await handleUserCreated(message.data)
        break
      case 'user.updated':
        await handleUserUpdated(message.data)
        break
    }
  }
}

3. CQRS with Serverless

// Command handler (write operations)
exports.commandHandler = async (event) => {
  const { command, data } = JSON.parse(event.body)

  switch (command) {
    case 'CREATE_USER':
      return await createUser(data)
    case 'UPDATE_USER':
      return await updateUser(data)
    case 'DELETE_USER':
      return await deleteUser(data)
  }
}

// Query handler (read operations)
exports.queryHandler = async (event) => {
  const { query, parameters } = event.queryStringParameters

  switch (query) {
    case 'GET_USERS':
      return await getUsersView(parameters)
    case 'GET_USER_PROFILE':
      return await getUserProfileView(parameters)
    case 'SEARCH_USERS':
      return await searchUsersView(parameters)
  }
}

// Read model updater
exports.readModelUpdater = async (event) => {
  for (const record of event.Records) {
    if (record.eventName === 'INSERT' || record.eventName === 'MODIFY') {
      await updateReadModel(record.dynamodb.NewImage)
    }
  }
}

Data Patterns

DynamoDB Single Table Design

// Single table design for multi-entity data
const TableName = 'AppData'

// User entity
const createUser = async (userData) => {
  const user = {
    PK: `USER#${userData.id}`,
    SK: `USER#${userData.id}`,
    GSI1PK: `USER#${userData.email}`,
    GSI1SK: `USER#${userData.email}`,
    Type: 'User',
    ...userData,
    CreatedAt: new Date().toISOString(),
  }

  await dynamodb.put({ TableName, Item: user }).promise()
  return user
}

// Post entity
const createPost = async (postData) => {
  const post = {
    PK: `USER#${postData.authorId}`,
    SK: `POST#${postData.id}`,
    GSI1PK: `POST#${postData.id}`,
    GSI1SK: `POST#${postData.id}`,
    Type: 'Post',
    ...postData,
    CreatedAt: new Date().toISOString(),
  }

  await dynamodb.put({ TableName, Item: post }).promise()
  return post
}

// Query patterns
const getUserPosts = async (userId) => {
  const result = await dynamodb
    .query({
      TableName,
      KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
      ExpressionAttributeValues: {
        ':pk': `USER#${userId}`,
        ':sk': 'POST#',
      },
    })
    .promise()

  return result.Items
}

Serverless Data Pipeline

// S3 trigger for data processing
exports.processUpload = async (event) => {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name
    const key = record.s3.object.key

    // Process the uploaded file
    await processFile(bucket, key)
  }
}

const processFile = async (bucket, key) => {
  // Download file from S3
  const file = await s3.getObject({ Bucket: bucket, Key: key }).promise()

  // Process data (e.g., image resizing, CSV parsing)
  const processedData = await processData(file.Body)

  // Store results
  await storeProcessedData(processedData)

  // Trigger next step in pipeline
  await sqs
    .sendMessage({
      QueueUrl: process.env.NEXT_STEP_QUEUE,
      MessageBody: JSON.stringify({
        bucket,
        key,
        processedAt: new Date().toISOString(),
      }),
    })
    .promise()
}

Authentication and Security

JWT with Lambda Authorizers

// Custom authorizer
exports.authorize = async (event) => {
  const token = event.authorizationToken?.replace('Bearer ', '')

  if (!token) {
    throw new Error('Unauthorized')
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET)

    return {
      principalId: decoded.sub,
      policyDocument: {
        Version: '2012-10-17',
        Statement: [
          {
            Action: 'execute-api:Invoke',
            Effect: 'Allow',
            Resource: event.methodArn,
          },
        ],
      },
      context: {
        userId: decoded.sub,
        email: decoded.email,
        role: decoded.role,
      },
    }
  } catch (error) {
    throw new Error('Unauthorized')
  }
}

// Protected function
exports.protectedHandler = async (event, context) => {
  const userId = event.requestContext.authorizer.userId
  const userRole = event.requestContext.authorizer.role

  // Use user context for business logic
  return {
    statusCode: 200,
    body: JSON.stringify({
      message: 'Access granted',
      userId,
      role: userRole,
    }),
  }
}

Environment-Based Configuration

// Configuration management
const config = {
  development: {
    dbTable: 'dev-users',
    apiUrl: 'https://dev-api.example.com',
    logLevel: 'debug',
  },
  staging: {
    dbTable: 'staging-users',
    apiUrl: 'https://staging-api.example.com',
    logLevel: 'info',
  },
  production: {
    dbTable: 'prod-users',
    apiUrl: 'https://api.example.com',
    logLevel: 'error',
  },
}

const getConfig = () => {
  const stage = process.env.STAGE || 'development'
  return config[stage]
}

module.exports = { getConfig }

Performance Optimization

Cold Start Mitigation

// Connection reuse
let dbConnection = null

const getDbConnection = async () => {
  if (!dbConnection) {
    dbConnection = await createConnection()
  }
  return dbConnection
}

// Provisioned concurrency configuration
const serverless = {
  functions: {
    api: {
      handler: 'handler.api',
      provisionedConcurrency: 5, // Keep 5 instances warm
      reservedConcurrency: 100, // Limit max concurrent executions
    },
  },
}

// Lazy loading
const AWS = require('aws-sdk')
let dynamodb = null

const getDynamoDB = () => {
  if (!dynamodb) {
    dynamodb = new AWS.DynamoDB.DocumentClient()
  }
  return dynamodb
}

Caching Strategies

// In-memory caching
const cache = new Map()

const getCachedData = async (key, fetchFunction, ttl = 300000) => {
  const cached = cache.get(key)

  if (cached && Date.now() - cached.timestamp < ttl) {
    return cached.data
  }

  const data = await fetchFunction()
  cache.set(key, { data, timestamp: Date.now() })

  return data
}

// ElastiCache integration
const redis = require('redis')
const client = redis.createClient({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
})

const cacheGet = async (key) => {
  const cached = await client.get(key)
  return cached ? JSON.parse(cached) : null
}

const cacheSet = async (key, value, ttl = 3600) => {
  await client.setex(key, ttl, JSON.stringify(value))
}

Monitoring and Observability

Structured Logging

// Centralized logging
const logger = {
  info: (message, metadata = {}) => {
    console.log(
      JSON.stringify({
        level: 'info',
        message,
        timestamp: new Date().toISOString(),
        requestId: context.awsRequestId,
        ...metadata,
      })
    )
  },

  error: (message, error = {}, metadata = {}) => {
    console.error(
      JSON.stringify({
        level: 'error',
        message,
        error: {
          message: error.message,
          stack: error.stack,
          name: error.name,
        },
        timestamp: new Date().toISOString(),
        requestId: context.awsRequestId,
        ...metadata,
      })
    )
  },
}

// Usage in Lambda
exports.handler = async (event, context) => {
  logger.info('Function started', { event })

  try {
    const result = await processEvent(event)
    logger.info('Function completed successfully', { result })
    return result
  } catch (error) {
    logger.error('Function failed', error, { event })
    throw error
  }
}

Custom Metrics

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

const putMetric = async (metricName, value, unit = 'Count', dimensions = {}) => {
  const params = {
    Namespace: 'MyApp/Lambda',
    MetricData: [
      {
        MetricName: metricName,
        Value: value,
        Unit: unit,
        Dimensions: Object.entries(dimensions).map(([Name, Value]) => ({
          Name,
          Value,
        })),
        Timestamp: new Date(),
      },
    ],
  }

  await cloudwatch.putMetricData(params).promise()
}

// Usage
await putMetric('UserCreated', 1, 'Count', { Environment: 'production' })
await putMetric('ProcessingTime', duration, 'Milliseconds')

Deployment Patterns

Infrastructure as Code

# serverless.yml
service: serverless-app

provider:
  name: aws
  runtime: nodejs18.x
  environment:
    STAGE: ${self:provider.stage}
    REGION: ${self:provider.region}

functions:
  api:
    handler: src/handlers/api.handler
    events:
      - http:
          path: /{proxy+}
          method: ANY
          cors: true

resources:
  Resources:
    UsersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:service}-${self:provider.stage}-users
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH

plugins:
  - serverless-webpack
  - serverless-offline
  - serverless-domain-manager

CI/CD Pipeline

# .github/workflows/deploy.yml
name: Deploy Serverless App

on:
  push:
    branches: [main, staging]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Deploy to staging
        if: github.ref == 'refs/heads/staging'
        run: npx serverless deploy --stage staging
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Deploy to production
        if: github.ref == 'refs/heads/main'
        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 }}

Best Practices

  1. Function Design: Keep functions small and focused
  2. Cold Starts: Minimize dependencies and use provisioned concurrency
  3. Error Handling: Implement proper error boundaries and retries
  4. Security: Use least privilege principles and environment variables
  5. Monitoring: Implement comprehensive logging and metrics
  6. Testing: Write unit tests and integration tests
  7. Cost Optimization: Monitor usage and optimize resource allocation

Conclusion

Serverless architecture offers powerful patterns for building scalable, cost-effective applications. Key benefits include:

  • Automatic Scaling: Handle traffic spikes without manual intervention
  • Cost Efficiency: Pay only for actual usage
  • Reduced Operations: Less infrastructure management
  • Faster Development: Focus on business logic over infrastructure

Success with serverless requires understanding these patterns and applying them appropriately to your use case. Start small, monitor performance, and iterate based on real-world usage patterns. ☁️