Skip to content

03 · Microservices & Serverless Patterns

Once a monolith genuinely outgrows a single deployable unit, two common paths open up: splitting into independently-deployed microservices, or going serverless and letting a cloud platform run individual functions on demand. Both trade simplicity for scalability and independence — know the cost before you pay it.

What makes a good microservice boundary

The most common microservices mistake is splitting by technical layer ("auth service", "database service") instead of by business capability. A good boundary aligns with a bounded context that a small team can own end to end.

Good boundaries (by business capability):
  orders-service      — owns order creation, status, history
  inventory-service    — owns stock levels, reservations
  notifications-service — owns email/SMS/push delivery

Bad boundaries (by technical layer):
  database-service     — every other service depends on this for all queries
  validation-service    — a shared library pretending to be a service

A useful test: can you deploy this service on its own, with its own datastore, without another team's release blocking you? If not, it's not really an independent service yet — it's a distributed monolith, which combines the operational cost of microservices with the coupling of a monolith.

Communication between services

Services talk to each other synchronously (HTTP/gRPC — simple, but couples availability) or asynchronously (message queue/event bus — decouples availability, adds eventual consistency).

// Synchronous: orders-service calls inventory-service directly over HTTP
async function reserveStock(orderId, items) {
  const response = await fetch("http://inventory-service/reservations", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ orderId, items }),
  });

  if (!response.ok) {
    throw new Error(`inventory-service rejected reservation: ${response.status}`);
  }

  return response.json();
}
// Asynchronous: orders-service publishes an event; inventory-service reacts
// (using a generic pub/sub client — the same shape applies to
// SQS/SNS, RabbitMQ, Kafka, or Redis Streams)

// orders-service — publisher
async function createOrder(order) {
  await db.orders.insert(order);
  await eventBus.publish("order.created", { orderId: order.id, items: order.items });
  return order;
}

// inventory-service — subscriber, runs independently
eventBus.subscribe("order.created", async (event) => {
  await reserveInventory(event.items);
});

Asynchronous messaging means orders-service doesn't go down if inventory-service is temporarily unavailable — the event waits in the queue — but it also means you must design for eventual consistency and duplicate delivery (make handlers idempotent).

Serverless / Functions-as-a-Service

Serverless platforms (AWS Lambda, Azure Functions, Google Cloud Functions, Cloudflare Workers) run your function on demand, scale to zero when idle, and bill per invocation instead of per running server. You write a handler function; the platform manages the runtime, scaling, and infrastructure.

// handler.js — an AWS Lambda-style handler for API Gateway (Node.js runtime)
export const handler = async (event) => {
  try {
    const body = JSON.parse(event.body ?? "{}");

    if (!body.email) {
      return {
        statusCode: 400,
        body: JSON.stringify({ error: "email is required" }),
      };
    }

    const user = await createUser(body.email);

    return {
      statusCode: 201,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(user),
    };
  } catch (error) {
    console.error("handler failed", error); // structured logs go to CloudWatch
    return {
      statusCode: 500,
      body: JSON.stringify({ error: "internal error" }),
    };
  }
};
# serverless.yml — a minimal Serverless Framework config for the handler above
service: users-api

provider:
  name: aws
  runtime: nodejs20.x
  region: us-east-1

functions:
  createUser:
    handler: handler.handler
    events:
      - httpApi:
          path: /users
          method: post

A key serverless discipline: handlers should be stateless and short-lived. Don't hold in-memory caches or long-lived connections you expect to persist across invocations — the platform may reuse the execution environment ("warm start") or spin up a brand-new one ("cold start") without notice.

// A defensive pattern: lazily initialize expensive resources
// so they're reused on warm starts but safely recreated on cold starts
let dbConnection;

async function getConnection() {
  if (!dbConnection) {
    dbConnection = await connectToDatabase(process.env.DATABASE_URL);
  }
  return dbConnection;
}

export const handler = async (event) => {
  const db = await getConnection();
  // ... use db
};

Microservices vs. serverless vs. monolith

Concern Monolith Microservices Serverless (FaaS)
Deployment unit One app Many independently-deployed services Individual functions
Scaling Whole app scales together Each service scales independently Automatic, per-function, to zero
Operational overhead Low High (networking, service discovery, observability) Low-to-medium (managed by platform)
Cold start latency None None (services stay running) Present, especially for infrequent functions
Best for Small-mid teams, most products Large orgs needing independent scaling/ownership Spiky/event-driven workloads, glue code
Local dev/debugging Easiest Hardest (many services to run) Moderate (emulators exist but imperfect)

Idempotency and retries

Distributed systems retry failed calls — your handlers must tolerate being invoked more than once for the same logical request without corrupting data.

// Idempotent handler using a client-supplied idempotency key
async function chargeCustomer(request) {
  const existingCharge = await db.charges.findByIdempotencyKey(request.idempotencyKey);
  if (existingCharge) {
    return existingCharge; // already processed — return the same result, don't double-charge
  }

  const charge = await paymentGateway.charge(request.amount, request.customerId);
  await db.charges.insert({ ...charge, idempotencyKey: request.idempotencyKey });
  return charge;
}

How It Actually Works

A serverless function's "cold start" is a real, measurable engine-boot cost: when no warm instance exists, the platform has to provision a new sandbox (often a lightweight VM or container), start a fresh Node process, initialize V8 from scratch, run your module's top-level code (all your require/import statements executing, any connections you open at module scope), and only then invoke your handler for the first time — every step here is exactly the same startup sequence as running node index.js locally, just happening on-demand under time pressure. This is why code that does expensive work at module scope (large JSON parsing, building lookup tables) shows up disproportionately in cold-start latency, and why platforms encourage keeping connections/clients as module-level singletons — so a warm invocation of the same sandboxed process reuses that already-initialized state instead of repeating the startup cost.

Microservices communicating over HTTP or a message queue trade in-process function calls (a single call stack, shared memory, synchronous or same-process-async) for network calls with an entirely different failure model: a function call either returns or throws, deterministically; a network call to another service can also simply never respond, forcing you to reason about timeouts as a first-class concern that has no equivalent when everything runs in one process. This is precisely why patterns like circuit breakers and retries with backoff exist for service-to-service calls but have no analog for calling a function defined in the same file.

Exercise

Design (no need to fully implement) a system for an e-commerce checkout flow split into orders-service, inventory-service, and payments-service. Sketch: (1) which service owns which data, (2) whether each interaction between services should be synchronous or asynchronous and why, and (3) one serverless function you'd add to this system (e.g. sending an order confirmation email) and why a FaaS function is a better fit for it than a long-running service.