Advanced (Updated: 6/3/2026)

Claude Code Microservices Guide: Boundaries, APIs, Compose, Testing

Use Claude Code for microservices: boundaries, API contracts, Compose, tests, observability, and rollout.

Claude Code Microservices Guide: Boundaries, APIs, Compose, Testing

Microservices are an architecture where a large application is split into small independent services that collaborate through APIs or events. Claude Code is useful here because it can keep service boundaries, API contracts, database ownership, local Docker Compose setup, observability, tests, and rollout notes in view at the same time.

The hard part is not generating folders. The hard part is avoiding a distributed monolith: many small services that still need one shared database, one synchronized release, and one person who understands every hidden dependency. This guide shows a practical workflow you can use before asking Claude Code to write production code.

For adjacent foundations, see API development with Claude Code, Docker Compose with Claude Code, logging and monitoring, and event-driven architecture.

Use the official references as review anchors: Anthropic Claude Code overview, Docker Compose documentation, and OpenAPI Specification 3.1. They make it easier to separate Claude Code suggestions from the actual contract you plan to operate.

Start With Boundaries

Before implementation, ask Claude Code to reason about business capabilities, data ownership, and failure modes.

You are reviewing a migration from a modular e-commerce app to microservices.

Goal:
- Orders change often.
- Inventory must evolve separately because warehouse integration changes monthly.
- Payment and notifications should not block browsing or catalog updates.

Return:
1. Candidate services and responsibilities.
2. Data owned by each service.
3. Which interactions should be synchronous APIs.
4. Which interactions should be events.
5. What should stay in the monolith for now.

Constraints:
- No shared database tables across services.
- No internal table names in public APIs.
- The API gateway must not contain business rules.
- Local development must run with Docker Compose.

This prompt forces Claude Code to explain why a boundary exists. Microsoft Learn’s microservices architecture guide is a useful reference for service autonomy, API contracts, and data isolation. If you plan to run on Kubernetes later, compare your design with the AKS microservices reference architecture.

Define The API Contract First

Do not start with Express handlers. Start with the contract that the gateway, frontend, tests, and downstream consumers can share.

openapi: 3.1.0
info:
  title: Order Service API
  version: 1.0.0
paths:
  /orders:
    post:
      summary: Create an order after reserving inventory
      operationId: createOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customerId, items]
              properties:
                customerId:
                  type: string
                items:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [sku, quantity]
                    properties:
                      sku:
                        type: string
                      quantity:
                        type: integer
                        minimum: 1
      responses:
        "201":
          description: Order accepted
        "400":
          description: Invalid request
        "409":
          description: Inventory could not be reserved

Then ask Claude Code to review backward compatibility: which fields can be added safely, which status codes are fixed, and which errors clients must handle. API gateways are useful for routing, authentication, throttling, and traffic policy, but not for hiding unstable service contracts. Microsoft’s API Management gateway overview is a good reference for the gateway role.

Make Data Ownership Explicit

Use a table in the design document and PR description.

ServiceOwnsMay callMust not do
gatewayno business dataorder, inventorycalculate discounts or stock
order-serviceorders, order_itemsinventory API, order event streamread inventory tables
inventory-servicestock, reservationsnone at firstread orders tables
notification-servicedelivery logsorder-eventschange order state

If a screen needs order and stock data together, do not join across service databases. Use an API composition endpoint, a read model, a search index, or an event-fed cache depending on latency needs.

For reviews, keep a small service-inventory.json in the repository. Humans own the boundary decision; Claude Code can check whether a change violates the inventory.

{
  "services": [
    {
      "name": "gateway",
      "owns": [],
      "mayCall": ["order-service", "inventory-service"],
      "mustNot": ["store business data", "calculate discounts"]
    },
    {
      "name": "order-service",
      "owns": ["orders", "order_items"],
      "mayCall": ["inventory-service"],
      "mustNot": ["read inventory tables directly"]
    },
    {
      "name": "inventory-service",
      "owns": ["stock", "reservations"],
      "mayCall": [],
      "mustNot": ["change order status"]
    }
  ],
  "releaseRules": [
    "no shared database tables",
    "public APIs hide internal table names",
    "every service has healthcheck, logs, tests, and rollback notes"
  ]
}

Copy-Paste Runnable Local Example

This sample runs a gateway, an order service, an inventory service, and Redis Streams. It is intentionally small but real.

mkdir microservices-demo
cd microservices-demo
mkdir services
npm init -y
npm pkg set type=module
npm install express zod pino redis undici

Create compose.yaml.

services:
  gateway:
    image: node:22-alpine
    working_dir: /workspace
    command: node services/service.mjs
    environment:
      SERVICE: gateway
      PORT: 3000
      ORDER_URL: http://order-service:3000
      INVENTORY_URL: http://inventory-service:3000
    ports:
      - "8080:3000"
    volumes:
      - .:/workspace
    depends_on:
      - order-service
      - inventory-service

  order-service:
    image: node:22-alpine
    working_dir: /workspace
    command: node services/service.mjs
    environment:
      SERVICE: order
      PORT: 3000
      INVENTORY_URL: http://inventory-service:3000
      REDIS_URL: redis://redis:6379
    volumes:
      - .:/workspace
    depends_on:
      redis:
        condition: service_healthy
      inventory-service:
        condition: service_started

  inventory-service:
    image: node:22-alpine
    working_dir: /workspace
    command: node services/service.mjs
    environment:
      SERVICE: inventory
      PORT: 3000
    volumes:
      - .:/workspace

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

Create services/service.mjs.

import express from "express";
import pino from "pino";
import { createClient } from "redis";
import { request } from "undici";
import { z } from "zod";
import { randomUUID } from "node:crypto";

const service = process.env.SERVICE ?? "inventory";
const port = Number(process.env.PORT ?? 3000);
const log = pino({ name: service });

function createApp(name) {
  const app = express();
  app.use(express.json());
  app.use((req, res, next) => {
    req.requestId = req.header("x-request-id") ?? randomUUID();
    res.setHeader("x-request-id", req.requestId);
    next();
  });
  app.get("/health", (_req, res) => res.json({ ok: true, service: name }));
  return app;
}

function startInventory() {
  const app = createApp("inventory");
  const stock = new Map([["sku-1", 5], ["sku-2", 2]]);
  const ReserveRequest = z.object({
    sku: z.string().min(1),
    quantity: z.number().int().positive(),
  });

  app.get("/inventory/:sku", (req, res) => {
    res.json({ sku: req.params.sku, quantity: stock.get(req.params.sku) ?? 0 });
  });

  app.post("/inventory/reservations", (req, res) => {
    const parsed = ReserveRequest.safeParse(req.body);
    if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
    const { sku, quantity } = parsed.data;
    const available = stock.get(sku) ?? 0;
    if (available < quantity) return res.status(409).json({ error: "insufficient_stock", sku, available });
    stock.set(sku, available - quantity);
    log.info({ requestId: req.requestId, sku, quantity }, "reserved inventory");
    res.status(201).json({ sku, reserved: quantity, remaining: stock.get(sku) });
  });

  app.listen(port, () => log.info({ port }, "inventory-service started"));
}

async function startOrder() {
  const app = createApp("order");
  const inventoryUrl = process.env.INVENTORY_URL ?? "http://localhost:3001";
  const redis = createClient({ url: process.env.REDIS_URL ?? "redis://localhost:6379" });
  redis.on("error", (error) => log.error({ err: error }, "redis error"));
  await redis.connect();

  const OrderRequest = z.object({
    customerId: z.string().min(1),
    items: z.array(z.object({
      sku: z.string().min(1),
      quantity: z.number().int().positive(),
    })).min(1),
  });

  app.post("/orders", async (req, res) => {
    const parsed = OrderRequest.safeParse(req.body);
    if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });

    try {
      for (const item of parsed.data.items) {
        const response = await request(`${inventoryUrl}/inventory/reservations`, {
          method: "POST",
          headers: { "content-type": "application/json", "x-request-id": req.requestId },
          body: JSON.stringify(item),
        });
        if (response.statusCode >= 400) {
          return res.status(response.statusCode).json(await response.body.json());
        }
      }
      const order = { id: randomUUID(), ...parsed.data, status: "accepted", createdAt: new Date().toISOString() };
      await redis.xAdd("order-events", "*", { type: "OrderAccepted", payload: JSON.stringify(order) });
      log.info({ requestId: req.requestId, orderId: order.id }, "order accepted");
      res.status(201).json(order);
    } catch (error) {
      log.error({ err: error, requestId: req.requestId }, "order failed");
      res.status(500).json({ error: "order_failed" });
    }
  });

  app.listen(port, () => log.info({ port }, "order-service started"));
}

function startGateway() {
  const app = createApp("gateway");
  const orderUrl = process.env.ORDER_URL ?? "http://localhost:3002";
  const inventoryUrl = process.env.INVENTORY_URL ?? "http://localhost:3001";

  async function forward(req, res, url) {
    const response = await request(url, {
      method: req.method,
      headers: { "content-type": "application/json", "x-request-id": req.requestId },
      body: req.method === "GET" ? undefined : JSON.stringify(req.body),
    });
    res.status(response.statusCode).send(await response.body.text());
  }

  app.post("/orders", (req, res) => forward(req, res, `${orderUrl}/orders`));
  app.get("/inventory/:sku", (req, res) => forward(req, res, `${inventoryUrl}/inventory/${encodeURIComponent(req.params.sku)}`));
  app.listen(port, () => log.info({ port }, "gateway started"));
}

if (service === "inventory") startInventory();
else if (service === "order") await startOrder();
else if (service === "gateway") startGateway();
else throw new Error(`Unknown SERVICE: ${service}`);

Run it.

docker compose up
curl http://localhost:8080/inventory/sku-1
curl -X POST http://localhost:8080/orders \
  -H "content-type: application/json" \
  -d '{"customerId":"cust-1","items":[{"sku":"sku-1","quantity":2}]}'
docker compose down

Observability, Testing, Rollout

At minimum, keep x-request-id from gateway to downstream services, write structured logs with service name and key IDs, add /health, and define latency/error metrics before the first release. Later you can add OpenTelemetry traces, but do not ship a distributed system with only console strings.

Create a small contract test.

import test from "node:test";
import assert from "node:assert/strict";
import { z } from "zod";

const OrderRequest = z.object({
  customerId: z.string().min(1),
  items: z.array(z.object({
    sku: z.string().min(1),
    quantity: z.number().int().positive(),
  })).min(1),
});

test("order contract accepts at least one item", () => {
  assert.equal(OrderRequest.safeParse({
    customerId: "cust-1",
    items: [{ sku: "sku-1", quantity: 1 }],
  }).success, true);
});

test("order contract rejects empty items", () => {
  assert.equal(OrderRequest.safeParse({ customerId: "cust-1", items: [] }).success, false);
});
node --test services/order-contract.test.mjs

Ask Claude Code to review the rollout checklist: API compatibility, database migration ownership, gateway logic, health checks, logs, 400/409/500 behavior, downstream outage behavior, feature flags, rollback, and canary release notes.

Use Cases And Pitfalls

Good use cases include e-commerce order/inventory/payment flows, B2B SaaS domains such as billing and audit logs, and media platforms where ingestion, transformation, search, and delivery scale differently. In each case, the services change at different speeds and failures should be isolated.

Avoid table-based service splitting, shared databases, huge shared domain libraries, business logic in the gateway, and pretending that distributed operations are one ACID transaction. If the domain is still changing daily, keep a modular monolith and let Claude Code help enforce module boundaries first.

The practical decision point is ownership. A microservice is worth introducing when one team can own the API, data migrations, alerts, release notes, and rollback for that boundary without waiting on the whole product. Ask Claude Code to produce a one-page service brief for each candidate: owner, API contract, database tables, events emitted, events consumed, dashboards, SLO, deployment command, rollback command, and the exact failure behavior when a dependency is down.

That brief also protects revenue work. If the service affects checkout, trial signup, ads, reporting, or consultation forms, add the monetization path to the checklist. A technically clean split that breaks attribution or email delivery is still a bad split. For a first pull request, prefer one service inventory file and one contract test over ten generated folders.

If you want reusable CLAUDE.md snippets, review checklists, and API contract templates instead of rebuilding the same notes each time, start from the ClaudeCodeLab practical template products.

If your team wants a hands-on review of boundaries, API contracts, Compose setup, observability, and rollout plans, the Claude Code training and consultation page is the soft next step. Start with one workflow, not a company-wide rewrite.

#Claude Code #microservices #API design #Docker Compose #observability #testing
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.