← Back to DevBytes

Serverless Architecture: When and How to Use It

Introduction to Serverless Architecture

Serverless architecture has fundamentally changed how developers build and deploy applications. Despite the name, "serverless" doesn't mean there are no servers — it means developers no longer have to manage them. Cloud providers handle provisioning, scaling, and maintenance, allowing you to focus entirely on writing business logic.

In this tutorial, we'll explore what serverless architecture is, when it makes sense to adopt it, and how to build production-ready serverless applications with practical code examples.

What Is Serverless Architecture?

Serverless architecture is a cloud computing model where the cloud provider dynamically manages the allocation and provisioning of servers. Developers write code as discrete functions that execute in response to events, and they are billed only for the compute time consumed — measured down to the millisecond.

There are two primary categories of serverless computing:

Most real-world serverless applications combine both FaaS and BaaS to deliver complete functionality without managing infrastructure.

Why Serverless Matters

Serverless architecture offers several compelling advantages that have driven its rapid adoption:

However, serverless is not a silver bullet. Understanding its trade-offs is essential before committing to it for a project.

When to Use Serverless

Ideal Use Cases

Serverless shines in specific scenarios where its characteristics align well with application requirements:

When to Avoid Serverless

Some workloads are poorly suited to serverless:

How to Build a Serverless Application

Let's build a practical serverless API using AWS Lambda and API Gateway. We'll create an endpoint that manages a simple task list stored in DynamoDB. The same patterns apply to other cloud providers with minor syntax differences.

Project Structure

First, set up your project directory and install the necessary tools:

mkdir serverless-tasks-api
cd serverless-tasks-api
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install -D serverless

Defining Infrastructure with Serverless Framework

The Serverless Framework provides a declarative way to define your infrastructure as code. Create a file named serverless.yml:

service: serverless-tasks-api

provider:
  name: aws
  runtime: nodejs18.x
  region: us-east-1
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:PutItem
            - dynamodb:GetItem
            - dynamodb:Scan
            - dynamodb:DeleteItem
          Resource:
            - !GetAtt TasksTable.Arn

functions:
  createTask:
    handler: src/handlers/createTask.handler
    events:
      - http:
          path: tasks
          method: post
          cors: true

  getTasks:
    handler: src/handlers/getTasks.handler
    events:
      - http:
          path: tasks
          method: get
          cors: true

  getTask:
    handler: src/handlers/getTask.handler
    events:
      - http:
          path: tasks/{id}
          method: get
          cors: true

  deleteTask:
    handler: src/handlers/deleteTask.handler
    events:
      - http:
          path: tasks/{id}
          method: delete
          cors: true

resources:
  Resources:
    TasksTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: TasksTable
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH

This configuration defines four Lambda functions, an API Gateway with REST endpoints, and a DynamoDB table — all deployed together as a single stack.

Writing the Lambda Handlers

Create the handler files in a src/handlers directory. Start with a shared DynamoDB client to avoid re-initializing it on every invocation:

// src/db.js
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient } = require('@aws-sdk/lib-dynamodb');

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

module.exports = { docClient };

Now create the handler for adding a new task:

// src/handlers/createTask.js
const { PutCommand } = require('@aws-sdk/lib-dynamodb');
const { docClient } = require('../db');

const TABLE_NAME = process.env.TASKS_TABLE || 'TasksTable';

exports.handler = async (event) => {
  try {
    const body = JSON.parse(event.body || '{}');

    if (!body.title) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ error: 'Title is required' }),
      };
    }

    const task = {
      id: crypto.randomUUID(),
      title: body.title,
      description: body.description || '',
      completed: false,
      createdAt: new Date().toISOString(),
    };

    await docClient.send(
      new PutCommand({
        TableName: TABLE_NAME,
        Item: task,
      })
    );

    return {
      statusCode: 201,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(task),
    };
  } catch (error) {
    console.error('Error creating task:', error);
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Internal server error' }),
    };
  }
};

Next, create the handler to list all tasks:

// src/handlers/getTasks.js
const { ScanCommand } = require('@aws-sdk/lib-dynamodb');
const { docClient } = require('../db');

const TABLE_NAME = process.env.TASKS_TABLE || 'TasksTable';

exports.handler = async (event) => {
  try {
    const result = await docClient.send(
      new ScanCommand({ TableName: TABLE_NAME })
    );

    return {
      statusCode: 200,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(result.Items || []),
    };
  } catch (error) {
    console.error('Error fetching tasks:', error);
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Internal server error' }),
    };
  }
};

Create the handler to retrieve a single task by ID:

// src/handlers/getTask.js
const { GetCommand } = require('@aws-sdk/lib-dynamodb');
const { docClient } = require('../db');

const TABLE_NAME = process.env.TASKS_TABLE || 'TasksTable';

exports.handler = async (event) => {
  try {
    const taskId = event.pathParameters?.id;

    if (!taskId) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ error: 'Task ID is required' }),
      };
    }

    const result = await docClient.send(
      new GetCommand({
        TableName: TABLE_NAME,
        Key: { id: taskId },
      })
    );

    if (!result.Item) {
      return {
        statusCode: 404,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ error: 'Task not found' }),
      };
    }

    return {
      statusCode: 200,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(result.Item),
    };
  } catch (error) {
    console.error('Error fetching task:', error);
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Internal server error' }),
    };
  }
};

Finally, create the handler to delete a task:

// src/handlers/deleteTask.js
const { DeleteCommand } = require('@aws-sdk/lib-dynamodb');
const { docClient } = require('../db');

const TABLE_NAME = process.env.TASKS_TABLE || 'TasksTable';

exports.handler = async (event) => {
  try {
    const taskId = event.pathParameters?.id;

    if (!taskId) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ error: 'Task ID is required' }),
      };
    }

    await docClient.send(
      new DeleteCommand({
        TableName: TABLE_NAME,
        Key: { id: taskId },
      })
    );

    return {
      statusCode: 204,
      body: null,
    };
  } catch (error) {
    console.error('Error deleting task:', error);
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Internal server error' }),
    };
  }
};

Deploying the Application

With all handlers in place, deploy the application using the Serverless Framework:

npx serverless deploy

The framework will package your code, create the CloudFormation stack, and output the API endpoints. You can then test your API with curl:

# Create a task
curl -X POST https://your-api-url/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn serverless", "description": "Complete this tutorial"}'

# List all tasks
curl https://your-api-url/tasks

# Get a specific task
curl https://your-api-url/tasks/your-task-id

# Delete a task
curl -X DELETE https://your-api-url/tasks/your-task-id

Best Practices for Serverless Applications

Optimize for Cold Starts

Cold starts occur when a function is invoked after being idle, requiring the provider to provision a new container. To minimize their impact:

Design for Idempotency

Serverless functions may be retried by the cloud provider in case of failures. Design handlers to be idempotent so duplicate executions don't cause side effects:

// Example: Idempotent task creation using a client-provided ID
exports.handler = async (event) => {
  const body = JSON.parse(event.body);
  const taskId = body.id || crypto.randomUUID();

  // Check if task already exists before creating
  const existing = await docClient.send(
    new GetCommand({ TableName: TABLE_NAME, Key: { id: taskId } })
  );

  if (existing.Item) {
    return {
      statusCode: 200,
      body: JSON.stringify(existing.Item),
    };
  }

  // Proceed with creation...
};

Separate Business Logic from Infrastructure

Keep your handler functions thin. Move business logic into separate, testable modules:

// src/services/taskService.js
class TaskService {
  constructor(docClient, tableName) {
    this.docClient = docClient;
    this.tableName = tableName;
  }

  async create(taskData) {
    const task = {
      id: crypto.randomUUID(),
      ...taskData,
      completed: false,
      createdAt: new Date().toISOString(),
    };
    await this.docClient.send(
      new PutCommand({ TableName: this.tableName, Item: task })
    );
    return task;
  }

  async list() {
    const result = await this.docClient.send(
      new ScanCommand({ TableName: this.tableName })
    );
    return result.Items || [];
  }
}

module.exports = { TaskService };
// src/handlers/createTask.js (refactored)
const { TaskService } = require('../services/taskService');
const { docClient } = require('../db');

const taskService = new TaskService(docClient, 'TasksTable');

exports.handler = async (event) => {
  try {
    const body = JSON.parse(event.body || '{}');
    if (!body.title) {
      return { statusCode: 400, body: JSON.stringify({ error: 'Title is required' }) };
    }
    const task = await taskService.create(body);
    return { statusCode: 201, body: JSON.stringify(task) };
  } catch (error) {
    console.error(error);
    return { statusCode: 500, body: JSON.stringify({ error: 'Internal server error' }) };
  }
};

Implement Proper Error Handling and Logging

Use structured logging to make debugging easier in cloud environments. Include correlation IDs to trace requests across multiple functions:

// src/utils/logger.js
const createLogger = (context = {}) => {
  return {
    info: (message, data = {}) => {
      console.log(JSON.stringify({ level: 'info', message, ...context, ...data, timestamp: new Date().toISOString() }));
    },
    error: (message, data = {}) => {
      console.error(JSON.stringify({ level: 'error', message, ...context, ...data, timestamp: new Date().toISOString() }));
    },
  };
};

module.exports = { createLogger };

Use Environment Variables for Configuration

Never hardcode configuration values. Use environment variables defined in your serverless.yml:

provider:
  environment:
    TASKS_TABLE: TasksTable
    LOG_LEVEL: ${opt:stage, 'dev'}

Monitor and Observe

Serverless applications require robust observability. Implement monitoring for:

Secure Your Functions

Follow the principle of least privilege when defining IAM roles. Only grant the specific permissions each function needs. Additionally:

Conclusion

Serverless architecture is a powerful paradigm that eliminates infrastructure management, scales automatically, and optimizes costs for the right workloads. By understanding when to apply it — event-driven processing, intermittent traffic, API backends — and when to avoid it — long-running processes, constant high traffic, tight latency requirements — you can make informed architectural decisions. The practical example in this tutorial demonstrated how to build a complete serverless CRUD API using AWS Lambda, API Gateway, and DynamoDB with the Serverless Framework. By following best practices around cold start optimization, idempotency, separation of concerns, observability, and security, you can build serverless applications that are maintainable, scalable, and cost-effective. As you gain experience, you'll develop an intuition for which parts of your systems benefit most from going serverless and which are better served by traditional infrastructure.

— Ad —

Google AdSense will appear here after approval

← Back to all articles