MongoDBのスキーマ設計で迷わない:埋め込みと$lookupの判断軸
MongoDBは埋め込みと参照の判断で詰む。アクセスパターン起点のドキュメント設計、複合インデックス、$lookupを避ける集計を、動くコードで解説。
「とりあえずコレクション作って、注文データ入れといて」
そう頼んで作った最初のMongoDBは、半年後に注文1件の表示が2秒かかるようになりました。原因はシンプルで、商品マスタを参照にしていたせいで、注文一覧を開くたびに何十回も別コレクションを引いていたんです。
NoSQLは柔軟だと言われます。でも現場で僕が痛い目を見たのは、まさにその柔軟さでした。埋め込みすぎればドキュメントが肥大化し、参照に寄せすぎれば毎回の結合が重くなる。MongoDBの設計は「正解が一つに決まらない」からこそ、判断軸を持っていないと必ずどこかで詰まります。
この記事の要点
- MongoDBは「コレクションを作って」ではなくアクセスパターン(どの画面で・どの単位を・どの順で読むか)から設計する
- 埋め込みか参照かは好みではなく、読み方・更新頻度・配列サイズの3軸で機械的に決める
- インデックスはクエリから逆算する。複合インデックスは等価→範囲→ソートの順序が効き方を左右する
- 集計は
$matchで先に絞る。注文時点のスナップショットを埋め込んでおけば$lookupを丸ごと避けられる explain("executionStats")を見ない設計は「動くけど遅い」になる。totalDocsExaminedで答え合わせする
僕はこの設計を、Claude Codeを「コード生成器」ではなく「設計レビュー担当」として使いながら詰めました。AIにMongoDBを任せるときの勘所も合わせて書きます。
まず決めるのはコレクション名じゃない
冒頭で僕がやらかしたのは、「注文を保存する」という目的だけ見て、データの構造を後回しにしたことです。RDBの感覚でusers products orders order_itemsに正規化して、毎回アプリ側で結合していました。これだとMongoDBを使う意味がほとんどありません。
MongoDBの設計は、業務の読み方が違えば正解も変わります。だから最初にやるのは、アクセスパターンを表にすることです。Claude Codeに渡すときも、いきなり「コレクションを作って」ではなく、次の表から始めると判断が安定しました。
| ユースケース | よく読む単位 | 設計の方向性 | 主なリスク |
|---|---|---|---|
| ECの注文履歴 | ユーザー別の注文一覧、注文詳細、月次売上 | 注文に商品名・価格を埋め込み、商品IDは参照として残す | 商品名変更を過去注文に反映してしまう |
| SaaSの監査ログ | 組織別、ユーザー別、期間別のイベント | 追記中心のドキュメント、複合インデックス、TTLを検討 | 全件スキャン、ログ肥大化 |
| CMSの記事管理 | slugで1件取得、カテゴリ別一覧、公開状態別一覧 | slugとstatusを明示し、一覧用インデックスを作る | 下書きや内部メモの露出 |
| サポートチケット | 顧客別一覧、ステータス別キュー、担当者別対応 | コメントは同時に読む範囲まで埋め込み、巨大な添付は分離 | コメント配列が伸び続ける |
この表を作る段階で、Claude Codeによるデータベース設計やAPI開発ガイドの観点を混ぜています。APIが「注文一覧には商品名だけ必要」なのか「商品詳細の最新情報も必要」なのかで、埋め込みと参照の判断がひっくり返るからです。
ちなみに同じNoSQLでも、キー設計が設計書そのものになるDynamoDBとは勘所がだいぶ違います。比較したい人はClaude CodeとAWS DynamoDB実践ガイドも読むと、ドキュメント指向とキーバリュー指向の差がはっきりします。
Claude Codeに渡す最初のプロンプト
僕がレビューを頼むときのプロンプトは、コレクション名を一切出しません。アクセスパターンと制約だけを渡します。
あなたはMongoDBの設計レビュー担当です。
次のEC注文機能について、アクセスパターンから先にデータモデルを設計してください。
要件:
- ユーザーは自分の注文一覧を新しい順に見る
- 注文詳細では購入時点の商品名、価格、カテゴリを表示する
- 商品マスタの価格変更は過去注文に影響させない
- 管理画面ではstatus別、月別、カテゴリ別に売上を集計する
- 注文作成時に在庫減算と決済記録も行うが、transactionは本当に必要な箇所だけに限定する
- ローカルで動くseed/testと、explainで確認するインデックスを出す
出力してほしいもの:
1. embeddingとreferenceの判断表
2. MongoDB Node.js Driverで動く実装
3. validation schema
4. index一覧と理由
5. aggregation pipeline
6. rollout checklist
返ってきた答えで僕が必ず確認するのは3点です。「よく一緒に読むデータを埋め込んでいるか」「独立して頻繁に更新されるデータを参照にしているか」「集計で必要な属性を注文時点のスナップショットとして残しているか」。EC注文ならitems.name items.price items.categoryは購入時点の事実なので埋め込み、商品マスタそのものはproductIdで参照を残す——この線引きがブレていなければ、だいたい良い設計です。
ローカルで動く最小セット
ここからは手を動かします。まずMongoDBを起動します。Dockerが使える環境なら1行で済みます。
docker run --name mongo-claude-demo -p 27017:27017 -d mongo:8
Node.js側は公式ドライバを使います。
npm init -y
npm install mongodb
npm install -D tsx typescript
mkdir -p src
次のファイルは、validation schema、インデックス、seed、集計、explainでの答え合わせまでを一度に走らせます。コピペでそのまま動きます。
// src/mongodb-workflow.ts
import { MongoClient, ObjectId } from "mongodb";
type OrderStatus = "pending" | "paid" | "shipped" | "cancelled";
type Product = {
_id?: ObjectId;
name: string;
category: string;
currentPrice: number;
};
type OrderItem = {
productId: ObjectId;
name: string; // 購入時点のスナップショット(あとでマスタが変わっても固定)
category: string; // 同上。これがあるから売上集計で$lookupが要らない
price: number;
quantity: number;
};
type Order = {
userId: ObjectId;
status: OrderStatus;
items: OrderItem[];
totalAmount: number;
createdAt: Date;
updatedAt: Date;
};
const uri = process.env.MONGODB_URI ?? "mongodb://localhost:27017";
const client = new MongoClient(uri);
async function main() {
await client.connect();
const db = client.db("claude_code_shop");
await db.dropDatabase();
// DB側にも最低限の検証を置く。アプリの型だけだと"paied"のようなタイポが素通りする
await db.createCollection<Order>("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["userId", "status", "items", "totalAmount", "createdAt", "updatedAt"],
properties: {
userId: { bsonType: "objectId" },
status: { enum: ["pending", "paid", "shipped", "cancelled"] },
totalAmount: { bsonType: ["int", "long", "double", "decimal"], minimum: 0 },
createdAt: { bsonType: "date" },
updatedAt: { bsonType: "date" },
items: {
bsonType: "array",
minItems: 1,
items: {
bsonType: "object",
required: ["productId", "name", "category", "price", "quantity"],
properties: {
productId: { bsonType: "objectId" },
name: { bsonType: "string" },
category: { bsonType: "string" },
price: { bsonType: ["int", "long", "double", "decimal"], minimum: 0 },
quantity: { bsonType: "int", minimum: 1 }
}
}
}
}
}
}
});
const products = db.collection<Product>("products");
const orders = db.collection<Order>("orders");
// インデックスはアクセスパターンから逆算する(後述)
await Promise.all([
orders.createIndex({ userId: 1, createdAt: -1 }, { name: "orders_by_user_recent" }),
orders.createIndex({ status: 1, createdAt: -1 }, { name: "orders_by_status_recent" }),
orders.createIndex({ "items.category": 1, createdAt: -1 }, { name: "orders_by_category_month" }),
products.createIndex({ category: 1, name: 1 }, { name: "products_by_category_name" })
]);
const productResult = await products.insertMany([
{ name: "Claude Code Workshop", category: "training", currentPrice: 48000 },
{ name: "MongoDB Review Template", category: "template", currentPrice: 9800 },
{ name: "Backend Consultation", category: "consultation", currentPrice: 120000 }
]);
const [workshopId, templateId, consultationId] = Object.values(productResult.insertedIds);
const userId = new ObjectId();
const now = new Date("2026-06-01T09:00:00.000Z");
await orders.insertMany([
{
userId,
status: "paid",
items: [
{ productId: workshopId, name: "Claude Code Workshop", category: "training", price: 48000, quantity: 1 },
{ productId: templateId, name: "MongoDB Review Template", category: "template", price: 9800, quantity: 2 }
],
totalAmount: 67600,
createdAt: now,
updatedAt: now
},
{
userId,
status: "shipped",
items: [
{ productId: consultationId, name: "Backend Consultation", category: "consultation", price: 120000, quantity: 1 }
],
totalAmount: 120000,
createdAt: new Date("2026-05-21T10:00:00.000Z"),
updatedAt: now
}
]);
// 1. ユーザー別注文一覧(orders_by_user_recent が効く)
const recentOrders = await orders
.find({ userId })
.sort({ createdAt: -1 })
.limit(10)
.toArray();
// 2. 月別×カテゴリ別の売上。$lookupなしで集計できているのがポイント
const revenueByCategory = await orders
.aggregate([
{ $match: { status: { $in: ["paid", "shipped"] } } }, // 先に絞る
{ $unwind: "$items" },
{
$group: {
_id: {
month: { $dateToString: { format: "%Y-%m", date: "$createdAt" } },
category: "$items.category"
},
revenue: { $sum: { $multiply: ["$items.price", "$items.quantity"] } },
quantity: { $sum: "$items.quantity" }
}
},
{ $sort: { "_id.month": 1, revenue: -1 } }
])
.toArray();
// 3. インデックスが本当に効いているか答え合わせ
const explain = await orders
.find({ userId })
.sort({ createdAt: -1 })
.limit(10)
.explain("executionStats");
const examined = explain.executionStats?.totalDocsExamined ?? 0;
if (recentOrders.length !== 2) throw new Error("seed failed");
if (revenueByCategory.length === 0) throw new Error("aggregation failed");
if (examined > 2) throw new Error(`index check failed: examined ${examined} docs`);
console.log(JSON.stringify({ recentOrders, revenueByCategory, examined }, null, 2));
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await client.close();
});
実行します。
npx tsx src/mongodb-workflow.ts
このコードが僕にとって大事なのは、「動く」だけじゃなく設計意図を検証しているところです。orders_by_user_recentが効いていれば、ユーザー別の注文一覧で余計なドキュメントを大量に読みません。$matchをpipelineの先頭に置けば、集計対象も先に絞れます。最後のif (examined > 2)は、インデックスが外れた瞬間にテストを赤くする門番です。ここで止まるおかげで「気づいたら全件スキャンしてた」が起きません。
Claude Codeには、この実行結果とexplainを貼って「スキャンが増える条件はないか」「index順序はこのアクセスパターンに合っているか」をレビューさせます。
埋め込みか参照か:3軸で機械的に決める
MongoDB設計で一番迷うのが、データを埋め込む(embedding)か参照する(reference)かです。embeddingは関連データを親ドキュメントの中に直接持つこと、referenceは別コレクションに置いてIDだけ持つこと。僕も昔は「なんとなく」で決めて、冒頭の2秒問題を踏みました。今は好みではなく、読み方と更新頻度とサイズで決めます。
| 判断軸 | 埋め込みが向く | 参照が向く |
|---|---|---|
| 読み方 | 常に親と一緒に読む | 単独で読むことが多い |
| 更新頻度 | 親と同じタイミングで変わる、または過去時点を保存したい | 独立して頻繁に変わる |
| サイズ | 配列が予測可能な上限に収まる | 配列が無制限に伸びる |
| 整合性 | 多少の重複を許容できる | 一つの真実を厳密に守りたい |
注文履歴では商品名と価格を埋め込むべきです。2026年6月1日に購入した価格は、翌月の商品マスタ変更で書き換わってはいけません。これは「正規化をサボった」のではなく、過去時点を保存するための意図的な選択です。一方、商品ページや在庫管理は商品マスタを参照します。サポートチケットなら、最新の数十件のコメントは埋め込み、添付ファイルや全文検索用の本文は別コレクションに逃がすのが現実的です。
僕がClaude Codeに必ず聞かせるのは「最大配列長」「1ドキュメントの想定サイズ」「過去データの意味」「更新衝突の許容度」の4つです。MongoDBには1ドキュメント16MBという上限があるので、無制限に増えるコメント・ログ・通知・明細を全部埋め込む設計は、いつか必ず壁にぶつかります。
インデックスはクエリから逆算する
インデックスは、検索条件・ソート・ページングをセットで考えます。今回の注文一覧はfind({ userId }).sort({ createdAt: -1 })なので、{ userId: 1, createdAt: -1 }が自然な形です。status別の管理画面は{ status: 1, createdAt: -1 }、カテゴリ別の月次集計は{ "items.category": 1, createdAt: -1 }が候補になります。
複合インデックスで効きを左右するのは、フィールドの順序です。経験則として「等価条件→範囲条件→ソート」の順に並べると素直に効きます。userIdで等価に絞ってからcreatedAtで降順ソートするなら、まさに{ userId: 1, createdAt: -1 }がその形です。
ただ、インデックスは多ければいいわけではありません。書き込みのたびに全インデックスが更新されるので、月1回しか開かない管理画面のために巨大な複合インデックスを足すと、本番の注文作成が遅くなります。僕は各インデックスに「対応するアクセスパターン」「使うクエリ」「消してよい条件」をコメントで残すようにしてから、半年後の棚卸しが楽になりました。
公式の考え方はMongoDB Indexesを確認してください。複合インデックスの順序が効き方にどう関わるかは、ここに正確に書かれています。
集計は早く絞って小さく流す、そして$lookupを避ける
Aggregation Pipelineは、MongoDB内でデータを段階的に変換・集計する仕組みです。便利な一方、APIリクエストごとに重いpipelineを走らせると、アプリ全体のボトルネックになります。僕が守っている順番はこうです。
$matchで対象期間やstatusを先に絞る(ここで母数を減らすのが最重要)- 必要な配列だけ
$unwindする $groupで集計する$sortや$limitは用途に合わせて最後に置く- 頻繁に使う重い集計は、夜間バッチや集計済みコレクションに逃がす
今回のコードで僕が一番こだわったのは、売上集計で$lookup(コレクション間の結合)を使っていないことです。注文にカテゴリのスナップショットを埋め込んでいるので、商品コレクションを引きにいく必要がありません。$lookupは便利ですが、結合先が大きいと一気に重くなります。「注文時点のカテゴリで売上を見る」という要件なら、埋め込みで結合自体を消すほうが速くて安定します。これが設計と集計が地続きになっている、ということです。
集計の詳細はMongoDB Aggregation Operationsが公式リファレンスです。
transactionは必要な境界だけに使う
MongoDBにもtransaction(複数の書き込みをまとめて成功/失敗させる仕組み)はあります。でも全部をtransactionで包むと重くなりがちです。使うべきは「一部だけ成功すると業務的に壊れる」境界——注文作成・在庫減算・決済記録のような場面です。逆に、閲覧数の加算・通知作成・検索インデックス同期のように、後続処理や再試行で回復できるものは、同じtransactionに入れません。
Atlasやreplica set環境では、次のように必要な範囲だけを囲みます。
import { MongoClient, ObjectId } from "mongodb";
export async function markOrderPaid(client: MongoClient, orderId: ObjectId, paymentId: string) {
const session = client.startSession();
try {
await session.withTransaction(async () => {
const db = client.db("claude_code_shop");
const orders = db.collection("orders");
const payments = db.collection("payments");
// 「pendingのときだけpaidにする」を条件に入れて二重決済を防ぐ
await orders.updateOne(
{ _id: orderId, status: "pending" },
{ $set: { status: "paid", updatedAt: new Date() } },
{ session }
);
await payments.insertOne(
{ orderId, paymentId, status: "captured", createdAt: new Date() },
{ session }
);
});
} finally {
await session.endSession();
}
}
注意点が一つ。ローカルの単体MongoDBコンテナではtransactionが使えない構成があります。withTransactionはreplica setかAtlasが前提なので、テスト環境は本番に寄せておきます。制約と使いどころはMongoDB Transactionsで確認してください。
僕がMongoDB設計でやらかした失敗5つ
正直に書きます。今の判断軸は、ぜんぶ過去の事故から逆算したものです。
1つ目は、RDBの正規化をそのまま持ち込んだこと。 users products orders order_itemsに分けて毎回アプリ側で結合し、注文一覧が遅くなりました。常に一緒に読む商品名や購入時価格は、注文に残すのが自然でした。
2つ目は、その反動で何でも埋め込んだこと。 コメント・ログ・通知を親ドキュメントの配列に入れ続けたら、更新競合が増えて16MB上限が見えてきました。「この配列は1年後に最大何件か」を見積もる癖をつけたら止まりました。
3つ目は、validation schemaを省いたこと。 status: "paied"のタイポとquantity: 0が本番に紛れ込み、月次集計が静かに壊れました。アプリの型チェックだけを信じてはいけません。DB側にも最低限の検証を置きます。
4つ目は、インデックスを作っただけで安心したこと。 explain("executionStats")を見ていなかったので、効いていないインデックスに気づけませんでした。今はtotalDocsExamined totalKeysExamined winningPlanをClaude Codeに貼って、想定と違う理由を説明させています。
5つ目は、集計を画面表示のたびに重くしたこと。 月次売上をリアルタイム集計していたら、管理画面を開くたびにDBが唸りました。毎回最新である必要がない数字は、キャッシュか集計済みコレクションに逃がすほうが安定します。
本番投入前のrollout checklist
公開前に僕がいつも見るリストです。
- 主要アクセスパターンを5〜10個に絞り、各クエリのインデックスを説明できる
db.createCollectionまたはマイグレーションでvalidation schemaを適用している- seedデータで一覧・詳細・集計・異常系を再現できる
explainで全件スキャンがないことを確認している- transactionを使う処理と使わない処理の境界を文書化している
- バックアップ・リストア・TTL・監査ログ・個人情報削除の運用が決まっている
- 既存RDBやPrismaとの二重書き込み期間がある場合、切り戻し手順がある
- APIレスポンスに内部フィールドや未公開データが混ざらない
- 負荷が高い集計はキャッシュ・バッチ・集計済みコレクションを検討している
実装の一次情報はMongoDB Node.js Driverです。ドライバのバージョン差・接続プール・エラー処理・型定義は、公式を見てからClaude Codeに反映させると、古いAPIを混ぜにくくなります。
よくある質問
Q. MongoDBとSQL(RDB)、どちらを選べばいい? A. 「いつも一緒に読むデータがひとかたまりで、読み方が決まっている」ならMongoDBが向きます。逆に、後から自由にJOINして集計したい・複雑な整合性をDBに担保させたいならRDBが楽です。迷ったらClaude Codeによるデータベース設計で要件を整理してから決めると失敗しにくいです。
Q. 埋め込みと参照、結局どっちがデフォルト? A. 「常に親と一緒に読み、サイズが予測できる」なら埋め込みをデフォルトに。無制限に伸びる配列や、独立して頻繁に更新されるデータは参照です。注文の商品名のように過去時点を固定したいものも埋め込みが正解です。
Q. インデックスは多めに張っておけば安心?
A. いいえ。書き込みのたびに全インデックスが更新されるので、使われないインデックスは書き込みを遅くするだけです。explainで実際に使われているか確認し、不要なものは消します。
Q. ローカルのMongoDBでtransactionが動かないのはなぜ?
A. withTransactionはreplica setかAtlasが前提だからです。単体コンテナでは使えない構成があります。テスト環境はreplica setに合わせるか、Atlasの無料枠で確認します。
Q. Claude Codeにスキーマ設計を丸投げしていい?
A. 丸投げは事故ります。アクセスパターン・最大配列長・過去データの意味・更新衝突の許容度を渡したうえで、出力をレビューする使い方がいちばん効きます。explain結果まで貼ると、インデックス追加よりクエリの順番を直すべき場面が見えてきます。
実際に試した結果
冒頭の「注文表示2秒」事件のあと、僕の設計の出発点は完全に変わりました。最初に決めるのは「注文時点の事実を埋め込み、最新マスタは参照する」の一線です。これを決めただけで、売上集計から$lookupが丸ごと消え、注文一覧のスキャンも最小になりました。
そしてClaude Codeにexplain結果まで渡してレビューさせると、面白いことに、インデックスを足すより「クエリの順番」や「集計の粒度」を直したほうが効く場面が多く見つかります。賢いAIに設計を丸投げするより、判断軸を自分が持ったうえで答え合わせの相棒として使う。これがMongoDBで遠回りせずに済む、今の僕のやり方です。
MongoDBの設計で難しいのは、サンプルコードより「どのデータを一緒に読むか」を言語化する部分です。自社の注文・記事・ログ・チケット機能を題材にレビューしたいなら、ClaudeCodeLabの研修・相談で、CLAUDE.md整備から既存APIのデータモデル診断までまとめて扱えます。手を動かす教材は教材一覧から探してみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。