Claude Codeでマイクロサービス設計を実装する実践ガイド
Claude Codeでマイクロサービスを設計・実装する手順。境界、API契約、DB所有、Compose、監視、テストまで解説。
マイクロサービスとは、大きなアプリを小さな独立サービスに分け、APIやイベントで連携する設計です。Claude Codeと相性がよいのは、単に複数ファイルを生成できるからではありません。サービス境界、API契約、データ所有権、ローカル起動、監視、テスト、段階リリースまで、変更範囲を横断して見直せるからです。
ただし、最初に厳しめに言うと、マイクロサービスは「小さく分ければ速くなる」設計ではありません。分割した瞬間に、ネットワーク遅延、API互換性、分散トランザクション、ログ相関、デプロイ順序という新しい仕事が増えます。小規模な管理画面や検証中のSaaSなら、最初はモノリスで十分なことも多いです。
Masaが小さなEC検証プロジェクトで試したときも、最初に失敗したのはコード生成ではなく境界決めでした。ordersテーブル、order_itemsテーブル、paymentsテーブルに合わせてサービスを切った結果、注文作成のたびに3つのサービスが同期呼び出しになり、ローカルでも障害再現が面倒になりました。やり直してからは「顧客が完了したい業務」を単位に境界を決め、Claude Codeにはその境界を守らせるレビュー役も兼ねさせました。
この記事では、Claude Codeを使ってマイクロサービスを現実的に設計・実装する流れを、コピーして動かせる最小コードと一緒に整理します。API開発の基礎は Claude Code API開発 に、ローカル環境は Docker Compose実践ガイド に、監視は ログ・モニタリング実装 に、非同期連携は イベント駆動アーキテクチャ にもつながります。
公式資料としては、Claude Codeの前提確認に Anthropic Claude Code overview、ローカル実行に Docker Compose documentation、API契約に OpenAPI Specification 3.1 を合わせて見ると、生成コードのレビュー基準を作りやすくなります。
全体像
最初にClaude Codeへ渡すべき情報は「作りたいサービス名」ではなく、業務フロー、データの持ち主、失敗時の扱いです。Microsoft Learnの Microservices architecture style でも、小さく自律したサービス、明確なAPI、サービスごとのデータ所有、観測性が重要な構成要素として説明されています。Azure Kubernetes Serviceに載せる場合の本番構成は AKS microservices reference architecture が参考になります。
flowchart LR
Client["Web / Mobile"] --> Gateway["API Gateway"]
Gateway --> Order["order-service"]
Gateway --> Inventory["inventory-service"]
Order --> Inventory
Order --> Events["order-events stream"]
Events --> Notify["notification-service"]
Order --> OrderDb["orders DB"]
Inventory --> InventoryDb["inventory DB"]
Gateway --> Logs["logs / metrics / traces"]
この図のポイントは、注文サービスが在庫DBを直接読まないことです。注文は在庫サービスのAPIを呼び、注文確定イベントを流します。通知サービスはイベントを購読します。API Gatewayは外部入口であり、認証、ルーティング、レート制限、ログ相関などの横断関心事を担当します。Gatewayに業務ルールを入れ始めると、全サービスがGatewayの変更待ちになりやすいので注意します。Microsoftの API Management gateway overview も、GatewayをAPIトラフィックの入口として扱う考え方を理解するのに役立ちます。
1. サービス境界プロンプト
Claude Codeにいきなり「ECをマイクロサービス化して」と頼むと、見た目だけのサービス一覧が出がちです。最初のプロンプトでは、境界を決める基準、禁止事項、出力形式を固定します。
あなたは既存ECアプリをマイクロサービスへ分割する設計レビュー担当です。
目的:
- 注文作成の変更頻度が高い
- 在庫引当は倉庫連携の都合で独立して変更したい
- 決済と通知は障害時に注文全体を止めたくない
出力してほしいもの:
1. サービス候補と責務
2. 各サービスが所有するデータ
3. 同期APIと非同期イベントの使い分け
4. 分割しない方がよい機能
5. 最初の1スプリントで作る最小構成
制約:
- 共有DBは禁止
- 他サービスの内部テーブル名をAPIに出さない
- Gatewayに業務ルールを置かない
- ローカルは Docker Compose で起動できること
このプロンプトで欲しい答えは「サービス数」ではなく「なぜその境界か」です。たとえば顧客、商品、注文、在庫、決済、通知を候補にしても、最初から6つ全部を作る必要はありません。PV成長や収益化を狙うプロダクトなら、最初の1スプリントでは order-service と inventory-service と gateway に絞り、決済や通知はイベント設計だけ先に置く方が現実的です。
2. API契約を先に固定する
マイクロサービスの失敗は、実装後にAPIの意味を変えるところから始まります。Claude Codeには、OpenAPIを先に書かせてから実装させます。API契約は、フロントエンド、Gateway、サービス、テストの共通言語になります。
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
ここでClaude Codeに追加で頼むのは、互換性の観点です。「このAPIを将来壊さずに拡張するなら、どのフィールドを追加可能にして、どのステータスコードを固定すべきか」と聞くと、実装前にレビューできます。生成されたコードが動くかだけでなく、半年後に別チームが使える契約かを確認します。
3. データベース所有権を表にする
共有DBは、マイクロサービスを一番簡単に壊す近道です。ローカル開発では1つのPostgreSQLを使う場合でも、スキーマとマイグレーションの所有者は分けます。Claude Codeには次の表を更新させ、PRレビューでもチェックします。
| Service | Owns | May call | Must not do |
|---|---|---|---|
| gateway | no business data | order, inventory | 在庫計算や割引計算 |
| order-service | orders, order_items | inventory API, event stream | inventoryテーブル参照 |
| inventory-service | stock, reservations | none at first | ordersテーブル参照 |
| notification-service | delivery logs | order-events | 注文状態の直接変更 |
実務では「一覧画面で注文と在庫を一緒に出したい」という要求が必ず来ます。そのときにDBをJOINしたくなりますが、読み取り用の投影テーブル、検索インデックス、イベント購読によるキャッシュを検討します。完全な正規化よりも、サービスの自律性を優先する場面が増えます。
Claude Codeにレビューさせるときは、次のような service-inventory.json をリポジトリに置くと議論がぶれにくくなります。人間が境界を決め、Claude Codeには「この変更は台帳に反していないか」を見張らせるイメージです。
{
"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"
]
}
4. ローカルComposeで最小構成を動かす
次のサンプルは、Gateway、注文、在庫、Redis StreamをDocker Composeで起動する最小構成です。決済のような外部APIは入れず、実際に動く範囲に絞っています。
mkdir microservices-demo
cd microservices-demo
mkdir services
npm init -y
npm pkg set type=module
npm install express zod pino redis undici
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
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),
});
async function reserveItem(item, requestId) {
const response = await request(`${inventoryUrl}/inventory/reservations`, {
method: "POST",
headers: { "content-type": "application/json", "x-request-id": requestId },
body: JSON.stringify(item),
});
const payload = await response.body.json().catch(() => ({}));
if (response.statusCode >= 400) {
const error = new Error("inventory_reservation_failed");
error.statusCode = response.statusCode;
error.payload = payload;
throw error;
}
return payload;
}
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) {
await reserveItem(item, req.requestId);
}
const order = {
id: randomUUID(),
customerId: parsed.data.customerId,
items: parsed.data.items,
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.warn({ requestId: req.requestId, err: error }, "order rejected");
res.status(error.statusCode ?? 500).json({
error: error.message,
details: error.payload ?? null,
});
}
});
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),
});
const text = await response.body.text();
const contentType = response.headers["content-type"];
if (typeof contentType === "string") {
res.setHeader("content-type", contentType);
}
res.status(response.statusCode).send(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}`);
}
起動して動作確認します。
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
このサンプルではDBの代わりにメモリを使っています。本番化するなら、order-serviceには注文DB、inventory-serviceには在庫DBを持たせ、マイグレーションも別々にします。Claude Codeへの次の依頼は「このメモリ実装をPostgreSQLに置き換え、サービスごとに別スキーマ、別マイグレーション、別Repositoryにしてください。ただし他サービスのテーブルを参照しないでください」がよいです。
5. Gateway、監視、テストを同時に入れる
Gatewayは「便利な巨大サービス」ではありません。外部向けの入口、認証、ルーティング、レート制限、リクエストID付与、レスポンス整形を担当させ、注文状態や在庫計算は各サービスに残します。今回のサンプルでは x-request-id をGatewayから下流へ流しているので、ログを追うと1つの注文作成が複数サービスにまたがる様子を確認できます。
監視は後付けにしない方がいいです。最低でも、構造化ログ、メトリクス、分散トレース、ヘルスチェックを最初のスプリントに含めます。OpenTelemetryを入れる前でも、リクエストID、サービス名、注文ID、HTTPステータス、処理時間をログに出すだけで障害調査は大きく楽になります。
契約テストは小さく始めます。services/order-contract.test.mjs を作ります。
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 one or more items", () => {
const parsed = OrderRequest.safeParse({
customerId: "cust-1",
items: [{ sku: "sku-1", quantity: 2 }],
});
assert.equal(parsed.success, true);
});
test("order contract rejects an empty item list", () => {
const parsed = OrderRequest.safeParse({ customerId: "cust-1", items: [] });
assert.equal(parsed.success, false);
});
node --test services/order-contract.test.mjs
Claude Codeには、単体テストだけでなく次の観点でレビューさせます。
- API契約と実装のズレがないか
- 409、400、500の使い分けが説明できるか
- Redis停止時に注文APIがどう失敗するか
x-request-idがGatewayから下流ログまで残るか- 冪等性が必要な操作にキーを持たせているか
6. 使いどころは3つ以上ある
マイクロサービスが向く代表例は、まずECや予約のように注文、在庫、決済、通知の変更頻度と障害影響が違うシステムです。在庫連携が遅れても商品閲覧は止めたくない、通知が落ちても注文受付は維持したい、という分離に価値があります。
2つ目はB2B SaaSです。請求、権限、監査ログ、ワークフローなど、責務が明確でチームやリリースサイクルが分かれやすい領域は候補になります。ただし、テナント情報を全サービスで勝手に複製すると事故が起きるので、契約と監査が重要です。
3つ目はメディアやコンテンツ基盤です。入稿、変換、検索インデックス、配信、分析は処理特性が違います。画像変換や検索再構築の負荷を、記事編集画面から切り離せるのは大きな利点です。
一方で、管理者が数人だけの社内ツール、まだドメインが固まっていない新規事業、単一チームで毎日大きく仕様が変わるプロトタイプは、無理に分けない方が速いです。Claude Codeには「分割しない理由」も出させてください。
英語で言えば、ここで見るべきなのは単なるuse caseではなく「障害を分離する価値がある業務単位」です。候補ごとに、変更多頻度、障害時の影響、データ所有者、rollback方法をClaude Codeに書かせると、流行語としてのマイクロサービスから離れて判断できます。
7. 具体的な落とし穴
最も多い失敗は、テーブル単位でサービスを作ることです。users-service、profiles-service、addresses-service のようにDB正規化をそのままサービス化すると、1画面の表示で多数の同期呼び出しが発生します。境界はテーブルではなく業務能力で決めます。
次に危険なのは共有ライブラリの肥大化です。ログ、エラー形式、認証ヘルパー程度なら共通化してもよいですが、ドメインモデルを共通ライブラリに入れると、全サービスが同時リリースを強いられます。Claude Codeが「便利なので共通型を作りました」と提案したら、契約型なのか内部モデルなのかを分けてレビューします。
分散トランザクションの錯覚もあります。注文、在庫、決済を1つのACIDトランザクションのように扱おうとすると、実装が急に重くなります。最初は補償処理、再試行、冪等性、イベントの再処理を設計した方が現実的です。
Gatewayに業務ロジックを入れるのも落とし穴です。「一時的にGatewayで割引判定する」変更は便利ですが、すぐにGatewayが第二のモノリスになります。Gatewayは入口であり、ドメイン判断はサービスへ戻します。
最後に、監視なしで分割することです。サービスが増えるほど、障害時の質問は「どこが落ちたか」ではなく「どのリクエストがどこで遅くなったか」になります。ログ、メトリクス、トレースを入れない分割は、運用コストを未来へ先送りしているだけです。
このpitfallを避けるには、最初のPRで「サービスを増やす」ことを成功条件にしないことです。成功条件は、境界の説明、契約テスト、障害時のログ、戻し方が1つの差分で読めることです。
8. ロールアウトチェックリスト
本番投入前に、Claude CodeへこのチェックリストをPR説明に埋め込ませます。
- サービス境界とデータ所有者が表で説明されている
- API契約に破壊的変更がない
- 追加フィールドは後方互換で扱える
- DBマイグレーションはサービス単位で戻せる
- Gatewayに業務ロジックが混ざっていない
- ログにサービス名、request ID、主要IDが出る
- ヘルスチェックと最低限のメトリクスがある
- 400、409、500の失敗パスをテストしている
- Redisや下流サービス停止時の挙動を確認した
- カナリア、Feature Flag、ロールバック手順がある
このチェックリストを満たせないなら、まだ分割しない判断も正解です。Claude Codeは速くコードを出せますが、運用設計の穴を自動で埋めてくれるわけではありません。むしろ、穴を見つけるためのレビュー相手として使う方が効果的です。
まとめ
Claude Codeでマイクロサービスを作るなら、順番は「境界、契約、データ所有、ローカルCompose、Gateway、監視、テスト、ロールアウト」です。コード生成を最初に置くと、薄いサービスが増えるだけです。境界と契約を先に固定し、Claude Codeに実装とレビューを往復させると、少人数でも現実的な品質に近づけます。
チェックリストやCLAUDE.md、API契約レビューの雛形をまとめて使いたい場合は、ClaudeCodeLabの 実務テンプレート集 から始めると、毎回ゼロから設計メモを書く手間を減らせます。
実際に試した結果
この記事で紹介した内容を実際に試した結果、最も効いたのはプロンプトの凝り方ではなく、PRごとに「この変更はどのサービスの所有物か」を確認する運用でした。境界が曖昧なまま生成したコードは後で捨てることになりますが、境界、API、失敗時の扱いを先に書いたときは、Claude Codeの修正提案もかなり安定しました。
自社プロダクトでサービス境界のレビュー、API契約、Docker Compose化、監視設計まで一緒に整えたい場合は、ClaudeCodeLabの Claude Code研修・相談 で実コードを前提に相談できます。いきなり全社標準を作るより、1つの業務フローで小さく検証するのが一番安全です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code Permission Receipt Pattern: 許可、証拠、ロールバックを残す運用
Claude Codeの権限運用を安全にする permission receipt。許可範囲、承認待ち、検証コマンド、CTA導線を記録します。
Claude CodeとCodex、結局どっち?事故らない“併用”の現実解
OpenAIのCodexとClaude Code、どっちが得意でどっちに任せる?両方を安全に併用する作業分担と権限・検証のワークフローを、僕の失敗談つきで解説します。
Claude Codeサブエージェント実装ガイド: 記事・コード作業を安全に並列委譲する方法
Claude Codeサブエージェントで記事・コード作業を安全に並列化する実装ガイド。委譲基準、プロンプト、失敗例を解説。