マイクロサービス分割で僕が最初に失敗した話とClaude Codeの使い方
テーブル単位で分けたら同期呼び出し地獄に。サービス境界・API契約・DB所有権をClaude Codeと固める実践手順を、動くComposeコード付きで解説。
「ECをマイクロサービスっぽく分けてみよう」。検証プロジェクトでそう思い立った僕は、手元のDBスキーマを眺めてorders、order_items、paymentsという3つのテーブルに合わせてサービスを切りました。
整然として見えました。少なくとも図の上では。
ところが注文を1件作るたびに3つのサービスが同期で呼び合い、ローカルでさえ障害の再現がしんどい。「速くするはずの分割」で、自分の首を絞めていたんです。
マイクロサービスは「小さく分ければ速くなる」設計ではありません。分割した瞬間に、ネットワーク遅延、API互換性、分散トランザクション、ログ相関、デプロイ順序という新しい仕事がまとめて増えます。今日はその失敗をどうやり直したか、そしてClaude Codeを「コード生成機」ではなく「境界の見張り役」として使うやり方を、コピペで動くコードと一緒に書きます。
この記事の要点
- マイクロサービスの良し悪しは「サービス数」ではなく境界の決め方で決まる。テーブル単位ではなく「顧客が完了したい業務」で切る。
- 失敗の元はだいたい、共有DB・Gatewayへの業務ロジック混入・テーブル単位の分割の3つ。
- Claude Codeは最初にコードを書かせるより、境界→API契約→データ所有権を先に固定してから実装させると安定する。
- API契約(OpenAPI)を先に書き、データ所有権を表とJSON台帳にして、Claude Codeに「この変更は台帳に反していないか」を見張らせる。
- ローカルはDocker Composeで最小構成(Gateway+注文+在庫+Redis)を実際に動かして確認する。この記事のコードはそのまま動く。
そもそもマイクロサービスって何で、いつ向くのか
マイクロサービスは、大きなアプリを小さな独立サービスに分け、APIやイベントで連携させる設計です。各サービスが自分のデータを持ち、自分のペースでリリースできる——ここに価値があります。
ただ、向き不向きがはっきりしています。向くのは、機能ごとに変更の頻度と障害の影響が違うシステムです。たとえばEC。商品閲覧は止めたくないけど在庫連携は遅れてもいい、通知が落ちても注文受付は維持したい。こういう「分離する理由」があるなら強い。
逆に、管理者が数人の社内ツール、まだドメインが固まっていない新規事業、毎日大きく仕様が変わるプロトタイプは、無理に分けない方が速いです。僕の最初の失敗も、要は分けるには早すぎたし、分け方も間違っていました。
Claude Codeと組む前提を確認するなら Anthropic Claude Code overview、設計判断の土台にはMicrosoft Learnの Microservices architecture style が読みやすいです。後者では「小さく自律したサービス」「明確なAPI」「サービスごとのデータ所有」「観測性」が要だと整理されていて、生成コードのレビュー基準を作るときの物差しになります。
全体像:注文サービスは在庫DBを直接読まない
やり直した構成の全体像はこうです。
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を呼び、確定したら注文イベントを流す。通知サービスはそのイベントを購読する。最初の僕はここで在庫テーブルを直接JOINして「楽だな」と思っていたんですが、それをやると2つのサービスが実質1つに癒着して、片方を直すたびにもう片方が壊れます。
API Gatewayは外部からの入口です。認証、ルーティング、レート制限、ログ相関といった横断的な関心事だけを担当させます。ここに業務ルールを入れ始めると、全サービスがGatewayの変更待ちになり、第二のモノリスが生まれます。Gatewayの役割の考え方は API Management gateway overview がわかりやすいです。
サービス境界は「業務」で切る:最初のプロンプト
Claude Codeにいきなり「ECをマイクロサービス化して」と頼むと、見た目だけのサービス一覧が返ってきます。僕がやり直してから使っているのは、境界を決める基準・禁止事項・出力形式を最初に固定するプロンプトです。
あなたは既存ECアプリをマイクロサービスへ分割する設計レビュー担当です。
目的:
- 注文作成の変更頻度が高い
- 在庫引当は倉庫連携の都合で独立して変更したい
- 決済と通知は障害時に注文全体を止めたくない
出力してほしいもの:
1. サービス候補と責務
2. 各サービスが所有するデータ
3. 同期APIと非同期イベントの使い分け
4. 分割しない方がよい機能
5. 最初の1スプリントで作る最小構成
制約:
- 共有DBは禁止
- 他サービスの内部テーブル名をAPIに出さない
- Gatewayに業務ルールを置かない
- ローカルは Docker Compose で起動できること
ここで欲しい答えは「サービス数」ではなく「なぜその境界か」です。顧客・商品・注文・在庫・決済・通知を候補に挙げても、最初から6つ全部を作る必要はありません。僕の検証プロジェクトでは、最初の1スプリントはorder-serviceとinventory-serviceとgatewayの3つに絞り、決済と通知はイベント設計だけ先に置きました。「4. 分割しない方がよい機能」をわざわざ聞くのがコツで、Claude Codeに分けない理由まで言わせると、流行語に流されずに済みます。
API契約を先に固定する
マイクロサービスの事故は、実装したあとにAPIの意味をこっそり変えるところから始まります。だから僕はClaude Codeに、実装より先にOpenAPIを書かせます。API契約は、フロントエンド・Gateway・各サービス・テストの共通言語になるからです。仕様の根拠としては OpenAPI Specification 3.1 を横に置いておくとレビューがぶれません。
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を将来壊さずに拡張するなら、どのフィールドを追加可能にして、どのステータスコードを固定すべきか」。こう聞くと、実装前にレビューできます。動くかどうかだけでなく、半年後に別チームが安心して使える契約になっているかを先に確かめておくわけです。
データベース所有権を表にする
共有DBは、マイクロサービスを一番手早く壊す近道です。ローカル開発で物理的に1つのPostgreSQLを使う場合でも、スキーマとマイグレーションの所有者は必ず分けます。僕はこの表をリポジトリに置いて、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したくなるんですが、グッとこらえて、読み取り用の投影テーブル・検索インデックス・イベント購読によるキャッシュを検討します。完全な正規化より、サービスの自律性を優先する場面が増える、と覚えておくといいです。
表だけだと議論がぶれるので、僕は同じ内容を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"
]
}
ローカル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です。1ファイルに3サービスをまとめていますが、本番では当然リポジトリを分けます。ここで注目してほしいのは、注文サービスが在庫をAPIで予約していること(reserveItem)と、x-request-idを下流まで引き回していることです。
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) => {
// リクエストIDを採番して下流へ引き回す(ログ相関の起点)
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) {
// 在庫不足は409で返す(注文側がこのコードで分岐できる)
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) {
// 在庫テーブルを直接見ず、在庫サービスのAPIを叩く
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) {
// Gatewayは入口。業務判断はせず、リクエストIDを付けて転送するだけ
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にしてください。ただし他サービスのテーブルは参照しないでください」。最後の一文を必ず付けるのがミソで、これがないとClaude Codeは平気で横断JOINを書いてきます。
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から下流ログまで残るか- 冪等性が必要な操作にキーを持たせているか
APIそのものの設計をもっと詰めたいときは Claude Code API開発、ローカル環境の作り込みは Docker Compose実践ガイド が土台になります。
僕がはまった落とし穴
正直に書くと、最初の検証は落とし穴のフルコースでした。
一番多いのが、冒頭で書いたテーブル単位の分割です。users-service、profiles-service、addresses-serviceみたいにDB正規化をそのままサービス化すると、たった1画面の表示で同期呼び出しが何本も走ります。境界はテーブルではなく業務能力で決める。これに尽きます。
次にやったのが、共有ライブラリの肥大化。ログやエラー形式、認証ヘルパー程度なら共通化していいんですが、ドメインモデルを共通ライブラリに入れたら、全サービスが同時リリースを強いられて身動きが取れなくなりました。Claude Codeが「便利なので共通型を作りました」と提案してきたら、それが契約型なのか内部モデルなのかを必ず分けてレビューします。
分散トランザクションの錯覚にもはまりました。注文・在庫・決済を1つのACIDトランザクションのように扱おうとした瞬間、実装が一気に重くなる。最初から補償処理・再試行・冪等性・イベントの再処理を設計した方が、結局は現実的でした。
そしてGatewayへの業務ロジック混入。「一時的にGatewayで割引判定だけ」——この一時的が曲者で、放っておくとGatewayが第二のモノリスになります。Gatewayは入口、ドメイン判断はサービスへ戻す。
最後が監視なしの分割です。サービスが増えるほど、障害時の問いは「どこが落ちたか」ではなく「どのリクエストがどこで遅くなったか」に変わります。ログ・メトリクス・トレースを入れない分割は、運用コストを未来の自分に丸投げしているだけでした。
ロールアウト前のチェックリスト
本番投入の前に、僕はこのチェックリストをPR説明に埋め込ませています。これを満たせないなら「まだ分割しない」も立派な正解です。
- サービス境界とデータ所有者が表で説明されている
- API契約に破壊的変更がない
- 追加フィールドは後方互換で扱える
- DBマイグレーションはサービス単位で戻せる
- Gatewayに業務ロジックが混ざっていない
- ログにサービス名・request ID・主要IDが出る
- ヘルスチェックと最低限のメトリクスがある
- 400・409・500の失敗パスをテストしている
- Redisや下流サービス停止時の挙動を確認した
- カナリア・Feature Flag・ロールバック手順がある
Claude Codeはコードを速く出せますが、運用設計の穴を自動で埋めてはくれません。むしろ、穴を見つけるためのレビュー相手として使う方が何倍も効きます。
よくある質問
Q. 最初からマイクロサービスで始めるべき? A. ほとんどの場合ノーです。ドメインが固まっていないうちはモノリスの方が速い。「変更頻度と障害影響が機能ごとに違う」とはっきり言えるようになってから分けても遅くありません。
Q. ローカル開発でもサービスごとにDBを分けるべき? A. 物理的に1つのPostgreSQLでも構いませんが、スキーマとマイグレーションの所有者は分けてください。共有テーブルに手を出した瞬間、サービスの独立性は消えます。
Q. サービス間は同期API(REST)と非同期イベント、どっちを使う? A. 即座に結果が要る予約・在庫引当などは同期API、結果を待たなくていい通知・分析などはイベント、が基本です。迷ったら「相手が落ちていてもこの処理は続けたいか」で判断します。
Q. Claude Codeに丸ごと設計させても大丈夫? A. 境界決めは人間がやるべきです。Claude Codeには境界・API契約・データ所有権を先に渡し、「この変更は台帳に反していないか」を見張らせる使い方が一番安定します。最初にコードから書かせると薄いサービスが量産されます。
Q. 何サービスくらいから始めればいい? A. 僕は3つ(Gateway+業務サービス2つ)から始めました。決済や通知はイベント設計だけ先に置いて、実装は後回し。最初の1スプリントで欲張らないのが安全です。
実際に試した結果
検証プロジェクトでひと通りやり直して、一番効いたのはプロンプトの凝り方ではありませんでした。PRごとに「この変更はどのサービスの所有物か」を確認する運用です。境界が曖昧なまま生成したコードは、結局あとで捨てることになりました。逆に、境界・API・失敗時の扱いを先に文章で書いてからClaude Codeに渡したときは、修正提案までかなり安定しました。
テーブルで切った最初の構成と、業務で切り直した構成。コードの量はほとんど変わらないのに、運用のしんどさは別物でした。マイクロサービスで悩んだら、サービスを増やす前に「分けない理由」をClaude Codeに書かせてみてください。それで止まれるなら、それが一番速い判断です。
チェックリストやCLAUDE.md、API契約レビューの雛形をまとめて使いたいなら、ClaudeCodeLabの 実務テンプレート集 から始めると、毎回ゼロから設計メモを書く手間が減ります。自社プロダクトの境界レビューやCompose化まで一緒に詰めたい場合は Claude Code研修・相談 で実コードを前提に相談できます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。