Serverless Architecture #

In 2014, AWS launched Lambda with a promise that sounded almost utopian to developers: write a function, upload it, pay only while it runs. No servers to provision, no capacity to estimate, no OS patches to apply at midnight. The reality is more nuanced — serverless does not mean there are no servers, only that the servers are not your responsibility. Serverless Architecture is an execution model in which the cloud platform manages all infrastructure: running your function when an event arrives, allocating resources as needed, then shutting everything down when there is no activity. You pay per millisecond of execution, not per server hour. For the right use cases — webhook handlers, image processing, scheduled jobs, event consumers with uneven traffic — it can be extremely cost-effective and very fast to develop. For the wrong use cases, it can become a nightmare in terms of latency, observability, and vendor lock-in.

The Serverless Execution Model #

flowchart TD
    subgraph TRADITIONAL["Traditional Server — Always On"]
        S["Server\\n24/7 running\\npaying continuously"]
        S -->|"handle"| R1["Request 1"]
        S -->|"handle"| R2["Request 2"]
        S -->|"idle"| IDLE["... no requests\\nstill paying"]
    end

    subgraph FN["Serverless Function — On-Demand"]
        E1["Event: HTTP Request"] -->|"trigger"| F1["Function Instance\\nrunning ~200ms"]
        E2["Event: Queue Message"] -->|"trigger"| F2["Function Instance\\nrunning ~500ms"]
        E3["Event: Cron"] -->|"trigger"| F3["Function Instance\\nrunning ~100ms"]
        F1 & F2 & F3 -->|"done"| OFF["Instances terminated\\nno cost"]
    end

The fundamental serverless characteristics that set it apart from other architectures:

CharacteristicExplanation
StatelessEvery invocation starts from a clean state — no shared memory between invocations
EphemeralContainers/runtimes can be destroyed at any time after execution finishes
Event-drivenFunctions only run when there is a trigger — HTTP, queue, cron, storage events
Auto-scalingThe platform automatically adds instances as traffic rises and removes them as it falls
Pay-per-executionCost is proportional to usage, not provisioned capacity

Cold Starts: The Reality to Understand #

Cold starts are one of serverless’s biggest trade-offs. When no instance is “warm”, the platform must create a new container, initialize the runtime, and run initialization code before a request can be processed.

sequenceDiagram
    participant C as Client
    participant P as Platform
    participant F as Function

    Note over C,F: Cold Start — no warm instance
    C->>P: Request arrives
    P->>P: Allocate container (50-200ms)
    P->>P: Initialize runtime (50-500ms)
    P->>F: Load function code (10-100ms)
    F->>F: Init code (DB conn, config) (50-500ms)
    F->>F: Handle request (10-200ms)
    F-->>C: Response (total: 200ms - 1500ms!)

    Note over C,F: Warm Start — instance already exists
    C->>P: Next request
    P->>F: Route to existing instance
    F->>F: Handle request (10-200ms)
    F-->>C: Response (total: 10-200ms)

Cold start mitigation strategies:

// ✓ Expensive initialization outside the handler — runs once during cold start
// On warm starts, the initialization is already done

package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	_ "github.com/lib/pq"
)

// Global variables initialized once during cold start
// ✓ Database connections are reused across warm invocations
var (
	db     *sql.DB
	config *AppConfig
)

// init() or package-level init — runs when the container is initialized
func init() {
	var err error

	// Load config
	config = loadConfig()

	// Open the database connection — expensive, but only once
	db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
	if err != nil {
		panic("database connection failed: " + err.Error())
	}

	// ✓ Set the connection pool for reuse on warm starts
	db.SetMaxOpenConns(5)    // serverless: do not keep too many connections
	db.SetMaxIdleConns(2)
}

// Handler is the function entry point — invoked on every request
func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	// ✓ db and config are ready — no need to reinitialize
	userID := req.PathParameters["id"]
	if userID == "" {
		return badRequest("user ID is required"), nil
	}

	var user struct {
		ID       string `json:"id"`
		FullName string `json:"full_name"`
		Email    string `json:"email"`
	}

	err := db.QueryRowContext(ctx,
		`SELECT id, full_name, email FROM users WHERE id = $1`, userID,
	).Scan(&user.ID, &user.FullName, &user.Email)

	if err == sql.ErrNoRows {
		return notFound("user not found"), nil
	}
	if err != nil {
		return serverError("failed to fetch user data"), nil
	}

	body, _ := json.Marshal(user)
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers:    map[string]string{"Content-Type": "application/json"},
		Body:       string(body),
	}, nil
}

func main() {
	lambda.Start(Handler)
}

func badRequest(msg string) events.APIGatewayProxyResponse {
	return events.APIGatewayProxyResponse{
		StatusCode: 400,
		Body:       `{"error":"` + msg + `"}`,
	}
}

func notFound(msg string) events.APIGatewayProxyResponse {
	return events.APIGatewayProxyResponse{
		StatusCode: 404,
		Body:       `{"error":"` + msg + `"}`,
	}
}

func serverError(msg string) events.APIGatewayProxyResponse {
	return events.APIGatewayProxyResponse{
		StatusCode: 500,
		Body:       `{"error":"` + msg + `"}`,
	}
}

Tips for reducing cold starts in Go:

Cold Start Mitigation:
  ✓ Go is already very fast at cold starts compared to Node.js or Python
  ✓ Initialize DB connections and config outside the handler (package level)
  ✓ Minimize external dependencies — every import adds binary size
  ✓ Provisioned Concurrency (AWS) — pay for instances that stay warm
  ✓ Ping the function periodically to keep it warm (CloudWatch Events)
  ✓ Use a connection pooler like PgBouncer between Lambda and PostgreSQL

Common Serverless Patterns #

Pattern 1: HTTP API Gateway + Lambda #

// functions/create_order/main.go — function handler for POST /orders
package main

import (
	"context"
	"encoding/json"
	"errors"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

type CreateOrderRequest struct {
	CustomerID string      `json:"customer_id"`
	Items      []OrderItem `json:"items"`
}

type OrderItem struct {
	ProductID  string `json:"product_id"`
	Quantity   int    `json:"quantity"`
	PriceCents int64  `json:"price_cents"`
}

type CreateOrderResponse struct {
	OrderID    string `json:"order_id"`
	TotalCents int64  `json:"total_cents"`
}

func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	var body CreateOrderRequest
	if err := json.Unmarshal([]byte(req.Body), &body); err != nil {
		return badRequest("invalid request body"), nil
	}

	// Validation
	if body.CustomerID == "" {
		return badRequest("customer_id is required"), nil
	}
	if len(body.Items) == 0 {
		return badRequest("items must not be empty"), nil
	}

	// Process the order
	orderID, total, err := createOrder(ctx, body)
	if err != nil {
		if errors.Is(err, ErrCustomerNotFound) {
			return notFound("customer not found"), nil
		}
		return serverError("failed to create order"), nil
	}

	respBody, _ := json.Marshal(CreateOrderResponse{
		OrderID:    orderID,
		TotalCents: total,
	})

	return events.APIGatewayProxyResponse{
		StatusCode: 201,
		Headers:    map[string]string{"Content-Type": "application/json"},
		Body:       string(respBody),
	}, nil
}

func main() {
	lambda.Start(Handler)
}

Pattern 2: SQS Consumer #

// functions/process_payment/main.go — function handler for SQS messages
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

type PaymentTask struct {
	OrderID    string `json:"order_id"`
	AmountCents int64 `json:"amount_cents"`
	Method     string `json:"method"`
}

// Handler is invoked with a batch of messages from SQS
func Handler(ctx context.Context, event events.SQSEvent) (events.SQSEventResponse, error) {
	var failures []events.SQSBatchItemFailure

	for _, msg := range event.Records {
		if err := processMessage(ctx, msg); err != nil {
			slog.ErrorContext(ctx, "failed to process message",
				"message_id", msg.MessageId,
				"error", err,
			)
			// ✓ Report as a failure — SQS will retry this message
			failures = append(failures, events.SQSBatchItemFailure{
				ItemIdentifier: msg.MessageId,
			})
		}
	}

	// ✓ Partial batch failure — only failed messages are retried
	return events.SQSEventResponse{BatchItemFailures: failures}, nil
}

func processMessage(ctx context.Context, msg events.SQSMessage) error {
	var task PaymentTask
	if err := json.Unmarshal([]byte(msg.Body), &task); err != nil {
		// ✗ Invalid message — do not retry, send to the DLQ
		slog.Warn("invalid SQS message, skipping", "message_id", msg.MessageId)
		return nil // return nil so it is not retried
	}

	slog.InfoContext(ctx, "processing payment",
		"order_id", task.OrderID,
		"amount_cents", task.AmountCents,
	)

	return processPayment(ctx, task)
}

func main() {
	lambda.Start(Handler)
}

Pattern 3: Scheduled Job (Cron) #

// functions/cleanup_expired_sessions/main.go — CloudWatch Events trigger
package main

import (
	"context"
	"database/sql"
	"log/slog"
	"time"

	"github.com/aws/aws-lambda-go/lambda"
)

var db *sql.DB

func init() {
	// Init DB connection
}

type ScheduledEvent struct {
	Source     string `json:"source"`
	DetailType string `json:"detail-type"`
}

func Handler(ctx context.Context, event ScheduledEvent) error {
	slog.InfoContext(ctx, "starting cleanup of expired sessions")

	cutoff := time.Now().Add(-24 * time.Hour)
	result, err := db.ExecContext(ctx,
		`DELETE FROM sessions WHERE expires_at < $1`, cutoff,
	)
	if err != nil {
		return err
	}

	rows, _ := result.RowsAffected()
	slog.InfoContext(ctx, "cleanup finished", "deleted_count", rows)
	return nil
}

func main() {
	lambda.Start(Handler)
}

State Management in Serverless #

Because functions are stateless and ephemeral, all state must live in external stores:

flowchart LR
    subgraph FN["Serverless Function\\n(stateless)"]
        H["Handler"]
    end

    subgraph EXT["External State Stores"]
        DB[(Database\\nRDS / DynamoDB)]
        CACHE[(Cache\\nElastiCache / Redis)]
        S3[(Object Storage\\nS3 / GCS)]
        SQ["Queue\\nSQS / Pub/Sub"]
    end

    H -->|"read/write data"| DB
    H -->|"session / temp data"| CACHE
    H -->|"files / blobs"| S3
    H -->|"async tasks"| SQ
// Example: upload a file + store metadata (stateless, all in external stores)
func HandleFileUpload(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	// Decode the file from the request body (base64)
	fileData, err := base64.StdEncoding.DecodeString(req.Body)
	if err != nil {
		return badRequest("invalid file"), nil
	}

	// Store to S3 — external storage
	fileName := generateFileName()
	if err := uploadToS3(ctx, fileName, fileData); err != nil {
		return serverError("failed to upload file"), nil
	}

	// Store metadata to DynamoDB — external state
	metadata := FileMetadata{
		FileID:    fileName,
		UserID:    req.RequestContext.Authorizer["userId"].(string),
		Size:      len(fileData),
		CreatedAt: time.Now().Unix(),
	}
	if err := saveMetadata(ctx, metadata); err != nil {
		return serverError("failed to save metadata"), nil
	}

	// Send an event for asynchronous processing (resize, virus scan, etc.)
	if err := sendToSQS(ctx, ProcessFileTask{FileID: fileName}); err != nil {
		slog.Warn("failed to send to SQS", "file_id", fileName)
	}

	body, _ := json.Marshal(map[string]string{"file_id": fileName})
	return events.APIGatewayProxyResponse{StatusCode: 201, Body: string(body)}, nil
}

Avoiding Vendor Lock-in #

One of the biggest serverless concerns is vendor lock-in — code depending directly on the AWS SDK or GCP SDK is hard to move to another platform.

// ✗ ANTI-PATTERN: business logic directly depending on the AWS SDK
package main

import (
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
)

func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	// ✗ Business logic inside a handler tightly coupled to AWS
	client := dynamodb.NewFromConfig(awsConfig)
	item, err := client.GetItem(ctx, &dynamodb.GetItemInput{
		TableName: aws.String("users"),
		Key: map[string]types.AttributeValue{
			"id": &types.AttributeValueMemberS{Value: req.PathParameters["id"]},
		},
	})
	// ... process item
}

// ✓ CORRECT: Separate business logic from the platform adapter
// The adapter is easy to swap without touching business logic

// core/user_service.go — pure business logic, no AWS dependencies
package core

type UserRepository interface {
	GetByID(ctx context.Context, id string) (*User, error)
}

type UserService struct {
	repo UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	if id == "" {
		return nil, errors.New("ID must not be empty")
	}
	return s.repo.GetByID(ctx, id)
}

// adapter/dynamo/user_repo.go — AWS-specific adapter
package dynamo

type DynamoUserRepo struct {
	client *dynamodb.Client
	table  string
}

func (r *DynamoUserRepo) GetByID(ctx context.Context, id string) (*core.User, error) {
	// AWS DynamoDB-specific code here
}

// handler/lambda/user_handler.go — thin AWS Lambda adapter
package lambda_handler

import (
	"github.com/aws/aws-lambda-go/events"
)

var svc *core.UserService // initialized in init()

func GetUserHandler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	id := req.PathParameters["id"]
	user, err := svc.GetUser(ctx, id) // ✓ call the business logic
	// ... format the response
}

With this separation, switching from DynamoDB to PostgreSQL only requires swapping the adapter — business logic stays unchanged. Switching from AWS Lambda to Google Cloud Functions only requires swapping the handler wrapper.


Serverless Go Directory Structure #

myapp/
├── core/                          ← Business logic — free of cloud SDKs
│   ├── user/
│   │   ├── service.go             ← UserService with interface dependencies
│   │   └── repository.go          ← UserRepository interface
│   └── order/
│       └── service.go
│
├── adapter/                       ← Implementations for specific technologies
│   ├── dynamo/
│   │   └── user_repo.go           ← DynamoDB implementation
│   ├── postgres/
│   │   └── user_repo.go           ← PostgreSQL implementation
│   └── s3/
│       └── file_storage.go        ← S3 implementation
│
├── functions/                     ← Entry point for each Lambda function
│   ├── get_user/
│   │   └── main.go                ← AWS Lambda handler + wiring
│   ├── create_order/
│   │   └── main.go
│   ├── process_payment/
│   │   └── main.go
│   └── cleanup_sessions/
│       └── main.go
│
├── template.yaml                  ← AWS SAM template (infra as code)
└── Makefile                       ← Build commands per function

Infrastructure as Code with AWS SAM #

# template.yaml — AWS SAM template
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime: provided.al2023   # Go on AWS Lambda ARM
    Architectures: [arm64]
    Timeout: 30
    MemorySize: 256
    Environment:
      Variables:
        DATABASE_URL: !Sub '{{resolve:ssm:/myapp/${Env}/database_url}}'
    Tracing: Active            # ✓ automatic X-Ray tracing

Resources:
  # GET /users/{id}
  GetUserFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/get_user/
      Handler: bootstrap
      Events:
        GetUser:
          Type: Api
          Properties:
            Path: /users/{id}
            Method: GET

  # POST /orders
  CreateOrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/create_order/
      Handler: bootstrap
      Events:
        CreateOrder:
          Type: Api
          Properties:
            Path: /orders
            Method: POST

  # SQS Consumer
  ProcessPaymentFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/process_payment/
      Handler: bootstrap
      Events:
        SQSQueue:
          Type: SQS
          Properties:
            Queue: !GetAtt PaymentQueue.Arn
            BatchSize: 10
            FunctionResponseTypes:
              - ReportBatchItemFailures  # ✓ partial batch failure

  PaymentQueue:
    Type: AWS::SQS::Queue
    Properties:
      VisibilityTimeout: 90
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt PaymentDLQ.Arn
        maxReceiveCount: 3

  PaymentDLQ:
    Type: AWS::SQS::Queue

  # Scheduled cleanup every day at 2 AM
  CleanupFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/cleanup_sessions/
      Handler: bootstrap
      Events:
        DailyCleanup:
          Type: Schedule
          Properties:
            Schedule: cron(0 2 * * ? *)

Anti-Patterns to Avoid #

// ✗ Storing state in variables modified between invocations
var requestCount int // ✗ this variable may be shared between invocations
                    // or reset at cold start — not reliable

func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (...) {
	requestCount++ // ✗ this state is inconsistent between invocations
}

// ✓ State that must persist goes into an external store
func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (...) {
	// ✓ update the counter in DynamoDB or Redis — consistent and scalable
	incrementRequestCount(ctx, "my-api")
}

// ✗ Database connections recreated on every invocation
func Handler(ctx context.Context, ...) (...) {
	db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL")) // ✗ expensive!
	defer db.Close()
	// ...
}

// ✓ Database connections initialized once at the package level
var db *sql.DB
func init() {
	db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL")) // ✓ once
}

// ✗ Functions that are too large — doing too many things
func HandleEverything(ctx context.Context, req events.APIGatewayProxyRequest) (...) {
	// ✗ handle all endpoints in one function
	switch req.Path {
	case "/users":
		handleUsers(ctx, req)
	case "/orders":
		handleOrders(ctx, req)
	case "/payments":
		handlePayments(ctx, req)
	}
}

// ✓ One function = one responsibility
// get_user/main.go only handles GET /users/{id}
// create_order/main.go only handles POST /orders

// ✗ Long-running processes in Lambda
func Handler(ctx context.Context, ...) (...) {
	// ✗ a process needing 20 minutes — will time out in Lambda (max 15 minutes)
	processLargeDataset(ctx) // loop over millions of records
}

// ✓ Split into small jobs via SQS
func Handler(ctx context.Context, ...) (...) {
	// ✓ send each batch to SQS for parallel processing
	batches := splitDatasetIntoBatches(dataset, 1000)
	for _, batch := range batches {
		sendToSQS(ctx, ProcessBatchTask{Records: batch})
	}
}

When Serverless, When Not #

Serverless is great for:
  ✓ Very uneven traffic (high spikes but long idle periods)
  ✓ Background jobs that do not always run (cron, webhooks, event consumers)
  ✓ Startups or MVPs wanting zero ops overhead at the beginning
  ✓ Truly stateless, independent functions
  ✓ Event processing: image resizing, email sending, notification dispatch

Avoid Serverless for:
  ✗ Long-running processes (>15 minutes) — use containers or VMs
  ✗ Applications needing very low, consistent latency — cold starts are not acceptable
  ✗ Systems with much complex internal state
  ✗ Database-heavy operations needing large connection pools
  ✗ Teams unfamiliar with the distributed and event-driven paradigm
  ✗ Systems needing full control over the runtime and environment

Serverless Architecture Review Checklist #

FUNCTION DESIGN:
  □ One function = one clear responsibility
  □ Handlers are thin — business logic lives in a separate package (core/)
  □ Function timeouts are configured according to the maximum expectation
  □ Memory size is calibrated based on profiling, not guesses

STATELESSNESS:
  □ No mutable global state modified between invocations
  □ All state is stored in external stores (DB, cache, queue, storage)
  □ Database connections use a connection pooler outside Lambda

COLD STARTS:
  □ Expensive initialization (DB, config) happens at the package level, not in handlers
  □ Unneeded dependencies are not imported
  □ Consider Provisioned Concurrency for endpoints with strict SLAs

ERROR HANDLING:
  □ SQS consumers use ReportBatchItemFailures for partial failures
  □ Dead Letter Queues are configured for all queues
  □ Errors are categorized: permanent (do not retry) vs transient (retry)
  □ Timeouts are handled with graceful shutdown

SECURITY:
  □ Each function has only the IAM permissions it needs (least privilege)
  □ Secrets and configuration live in Parameter Store or Secrets Manager, not plaintext env vars
  □ Input from API Gateway is validated at the start of the handler

OBSERVABILITY:
  □ Structured logging with correlation IDs
  □ Distributed tracing (X-Ray, Cloud Trace) is enabled
  □ Custom metrics for business events
  □ Cold start frequency is monitored

INFRASTRUCTURE AS CODE:
  □ All resources are defined in IaC (SAM, Terraform, CDK)
  □ No resources are created manually through the console
  □ The deployment pipeline uses IaC, not manual uploads

Summary #

  • Serverless does not mean no servers — servers are fully managed by the cloud platform; you only focus on code and business logic.
  • Cold starts are a real trade-off — expensive initialization (DB, config) must happen at the package level, not in handlers; Go is already faster than Node.js and Python for cold starts.
  • Statelessness is mandatory — every piece of state must live in an external store; never rely on local variables to hold state between invocations.
  • One function = one responsibility — oversized functions are hard to test, deploy, and debug; split them into small, specific functions.
  • Separate business logic from platform adapters — use interfaces for dependencies like repositories and storage; adapters are easy to swap without touching business logic when moving platforms.
  • SQS partial batch failures — do not fail an entire batch because of one problematic message; use ReportBatchItemFailures and send problematic messages to the DLQ.
  • Infrastructure as Code is mandatory — manual deploys through the console are unsustainable; use SAM, Terraform, or CDK from day one.
  • Vendor lock-in can be mitigated — by separating business logic from cloud SDKs, migrating between platforms becomes swapping adapters, not a full rewrite.
  • Observability matters even more than in other architectures — distributed, ephemeral, and event-driven make debugging very hard without structured logging, distributed tracing, and good metrics.
  • Not a universal solution — serverless is very strong for event-driven and uneven traffic, but poor for long-running processes, latency-sensitive workloads, or complex stateful systems.

← Previous: CQRS   Next: MVC →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact