[{"content":"はじめに こんにちは。and factory フロントエンドエンジニアの坂内です。\nワイヤーフレームを作るとなればFigmaが候補に挙がりますが、普段はデザイン確認専用に使っていました。いざ自分で作るとなると使い方を調べながら進める必要があり、「もっと手軽に作れないか」と思っていました。ちょうど機能追加でディレクターへ共有するためのモックが必要になったタイミングで、AnthropicのAIデザインツール「Claude Design」を見つけ、試してみました。\nあくまでワイヤーやモック用途での活用として、「ディレクターやデザイナーと連携するフロントエンドエンジニアにとって、どう使えるか」という観点で使いみちを探りました。\n注意： Claude Designはリサーチプレビュー段階のツールです。UIや機能仕様は今後変更される可能性があります。本記事の内容は2026年6月時点の情報に基づいており、最新の状況と異なる場合があります。\n結論 先に持ち帰ってほしいポイントを3つに絞ります。\nスクリーンショット＋テキスト指示だけでデザイントーンに合ったワイヤーが生成できる（既存画面のピンク×サイドバー構成を再現） バリデーション・APIペイロードまで自発的に実装してくれるため、エンジニアへの引き継ぎ資料が副産物として得られる 「Claudeチャットで仕様を固めてからDesignに渡す」2段階フローが現実的。Claude Designは細かい修正の往復には向かない Claude Designとは Claude Designは2026年4月17日にAnthropicがリリースしたAIデザインツールです（Anthropic Labs製・リサーチプレビュー）。\nclaude.ai 内の専用ワークスペースとして動作し、左ペインのチャットで指示すると、右ペインのライブキャンバスへリアルタイムに反映される2画面構成が特徴です。なお、これは今回試したUIに基づく観察です。公式発表ではインラインコメント・直接編集・調整ノブなどのインタラクションが紹介されています。\n項目 内容 リリース 2026年4月17日（Anthropic Labs製・リサーチプレビュー） 対象プラン Pro / Max / Team / Enterprise モデル Claude Opus 4.7（デフォルト） できること プロトタイプ・スライド・ワンページャー・HTML書き出し トークン 通常チャットとは別枠（プランにより異なる） アクセス方法 claude.ai にログイン後、左サイドバーの「Design」から起動できます。Pro / Max / Team / Enterpriseプランが対象です。\nNotionコネクタを使う場合は事前設定が必要です。Settings → IntegrationsからNotionを接続しておくと、Design内でNotionのURLをそのまま渡して仕様ページを直接読み込めます。\n実践：管理画面のワイヤーを作る 概要が掴めたところで、実際に試した内容を紹介します。\n最初に渡したプロンプト 今回は自社サービスの管理画面に「施策管理ページ」を追加する想定でワイヤーを作成しました。最初のプロンプトはシンプルにしました。\n1 2 3 4 5 6 7 XXXXXという管理画面の施策管理ページを作りたい。 - PC向け管理画面 - ピンク（#E91E8C）のヘッダー＋左サイドバー構成 - 施策一覧テーブル（ID・施策名・開始日・終了日・URL・ステータス） - 詳細ボタン → モーダルで編集 - モーダル内はアコーディオンで項目を折りたたむ - ステータスは「下書き / 公開 / 非公開」の3種 これだけでClaude Designから10問のヒアリングが返ってきて、設計が始まりました。\nインプットとして渡したもの Notionの仕様ページ（管理画面UIワイヤー案）→ Claudeチャット経由で要約してからDesignに渡した 既存管理画面のスクリーンショット（ピンク×サイドバー構成）→ デザイントーンの参照に使用 ヒアリング10問への回答 → Claude Design側からの設計前の質問に回答した 今回はNotionの仕様を一度Claudeチャットで要約してからDesignに渡しました。しかし作業後に、NotionコネクタをDesignに接続しておけばURLを渡すだけで直接読み込めると分かりました。最初から連携しておけば、コピペのひと手間を省けました。\nNotionとの連携方法の違い アプローチ 手順 感想 Claudeチャット経由（今回） Notionを読んだチャットに要約してもらい、その内容をDesignへ貼る 確実だが手間がかかる。要約精度はClaude次第 Notionコネクタ直接接続 Designのコネクタ設定でNotionを接続し、URLを渡すだけ 手順が少なく、仕様変更の反映もスムーズ 後からDesignで直接読み込めることに気づいたので、次回からはコネクタを先に設定する方が効率的です。\n生成された一覧画面 Claude Designが既存の管理画面デザイン（ピンクヘッダー・左サイドバー）に合わせた形で一覧画面を生成しました。\n自動で実装された要素：\nピンク（#E91E8C）ヘッダー + 左サイドバー（11メニュー） 施策一覧テーブル（ID・施策名・公開開始日時・公開終了日時・遷移先URL・ステータスの6列） ステータスバッジ（公開=緑 / 下書き=グレー / 非公開=赤） 施策名テキスト検索 + ステータスセグメントフィルタ ページネーション（Rows per page切替・件数表示） 生成された詳細編集モーダル 詳細ボタンクリックで開く大型モーダルでは、指示していない要素までClaude Designが自発的に実装しました。\n自動で実装された要素：\n共通項目（施策名・ステータス・公開期間・URL） 5つのアコーディオン（メニュー/表示制御・ポイント付与・プレキャン・無料鑑定・運用メモ） バリデーション全網羅（必須・形式・期間整合・数値範囲・文字数 / blur時＋保存時） エラー時にアコーディオンが自動展開して該当箇所を表示 APIペイロードプレビューパネル（POST /api/admin/campaigns/{id} + 整形JSON + コピーボタン） APIペイロードの例はこのような形で生成されました。\n1 2 3 4 5 6 7 8 9 10 11 { \u0026#34;id\u0026#34;: 101, \u0026#34;name\u0026#34;: \u0026#34;サンプル施策A・春のキャンペーン\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;公開\u0026#34;, \u0026#34;publishStart\u0026#34;: \u0026#34;2026/03/01 00:00\u0026#34;, \u0026#34;publishEnd\u0026#34;: \u0026#34;2026/05/31 23:59\u0026#34;, \u0026#34;redirectUrl\u0026#34;: \u0026#34;/sample/campaign/a-001\u0026#34;, \u0026#34;point\u0026#34;: { \u0026#34;amount\u0026#34;: 200, \u0026#34;condition\u0026#34;: \u0026#34;purchase\u0026#34; }, \u0026#34;preAnnouncement\u0026#34;: { \u0026#34;enabled\u0026#34;: true, \u0026#34;startAt\u0026#34;: \u0026#34;2026/02/15 12:00\u0026#34; }, \u0026#34;freeReading\u0026#34;: { \u0026#34;enabled\u0026#34;: true } } エンジニアへの引き継ぎ時に「このペイロード構造でAPIを作って」と渡せる状態になっているのが実用的でした。\n通常Claudeチャットとの使い分け 2つを使い比べてみて、使い分けの感覚が掴めてきました。\n観点 通常Claudeチャット Claude Design UI チャット画面のみ チャット＋ライブキャンバスの2ペイン トークン 通常チャット枠 専用枠（別カウント） モデル Claude Sonnet 4.6など Claude Opus 4.7（デフォルト） Notion連携 直接読み込み可 Notionコネクタ接続で読み込み可 やり取りの速さ 速い・細かい修正がすぐ反映 やや重め（生成に時間がかかる） HTML書き出し なし 自己完結ファイルなど APIスキーマ生成 限定的 自動生成（ペイロードプレビューまで） ドキュメント自動生成 なし 仕様まとめ・意思決定ログ込みで自動生成 ファイル管理 なし プロジェクト単位でフォルダ管理 実務フローへの組み込み方 今回の実践から見えてきた、現実的な使い方はこのフローです。\n1 2 3 4 5 6 7 ① Claudeチャット（Notion連携） └ 仕様のすり合わせ・細かい修正の反映・ディレクターとの合意取り ↓ 仕様が固まったら ② Claude Design └ 本格的な実装・ドキュメント自動生成・HTML書き出し ↓ 成果物をそのまま ③ Figmaで清書 または エンジニアへ直接渡す ポイントは、Claudeチャットで先に仕様を固めてからDesignに渡すことです。今回この流れで進めたことで、Claude Design側の10問ヒアリングにすぐ答えられ、最初の生成からほぼ意図通りのアウトプットが得られました。\n共有方法の選択肢 Claude Designでは以下の方法で生成物を共有できます。\n方法 特徴 向いているシーン 自己完結HTML 依存全内包・ネット不要・ダブルクリックで開ける（今回は8.6MB） 長期保管・Slack/Drive配布 一時公開URL ファイル送付不要・約1時間で失効 社内レビュー会の直前共有 Netlify Dropにデプロイ 恒久URL・無料 外部共有・長期公開 良かった点・課題 良かった点 既存スクリーンショットを渡すだけでデザイントーンをほぼ合わせてくれた バリデーションやAPIペイロードまで「言えばやってくれる」のが想定以上 ドキュメントが自動生成される（意思決定ログ・次のステップ付き）ので、エンジニアへの引き継ぎ資料がほぼできあがる HTMLとして書き出せるので、Slackで共有してブラウザで動かして確認してもらえる ディレクター・デザイナーへの共有の反応がとても良かった。モック形式なのでボタンやフィルターの動きも見せられ、実装イメージを掴んでもらいやすかった 約3回のやり取りで完成しました。①要件の提示 → ②Claude Designからの質問への回答 → ③実際に触ってみて気になった点の微調整、という流れでラリーが長引かず、満足度の高い体験でした 課題・注意点 Notionコネクタの事前設定が必要（接続済みであれば直接読み込み可。今回は当初知らずにClaudeチャット経由で対処したが、設定すれば解消できた） Claude Opus 4.7使用でトークン消費が多め（今回1プロジェクトで専用枠の約39%を消費） 通常チャットより1回あたりの生成に時間がかかるため、細かい修正の往復はやや重い 生成コードはレビュー必須（本番投入レベルではない） 簡易的なワイヤーや仕様確認レベルであれば、通常Claudeチャットで十分なケースもある おわりに Claude Designは「動くワイヤーをすぐ作って関係者に共有する」という用途でとても実用的なツールでした。特にAPIペイロードまで自動生成してくれる点は、フロントエンドとバックエンドの連携を早める可能性があります。\n一方でトークン消費や重さを考えると、すべての場面で使うのではなく、仕様が固まったタイミングで使うツールという位置づけが適切です。\n次はStitchも試してみる予定です。最終的なツール比較は別途まとめますが、引き続き実践しながら使い分けを整理していきます。\n参考リンク Claude Design公式発表（Anthropic） Claude Design（claude.ai） TechCrunch：Anthropic launches Claude Design ","permalink":"https://andfactory.co.jp/techblog/posts/claude-design-ai-wireframe-practice","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eこんにちは。and factory フロントエンドエンジニアの坂内です。\u003c/p\u003e\n\u003cp\u003eワイヤーフレームを作るとなればFigmaが候補に挙がりますが、普段はデザイン確認専用に使っていました。いざ自分で作るとなると使い方を調べながら進める必要があり、「もっと手軽に作れないか」と思っていました。ちょうど機能追加でディレクターへ共有するためのモックが必要になったタイミングで、AnthropicのAIデザインツール「Claude Design」を見つけ、試してみました。\u003c/p\u003e\n\u003cp\u003eあくまでワイヤーやモック用途での活用として、「ディレクターやデザイナーと連携するフロントエンドエンジニアにとって、どう使えるか」という観点で使いみちを探りました。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e注意：\u003c/strong\u003e Claude Designはリサーチプレビュー段階のツールです。UIや機能仕様は今後変更される可能性があります。本記事の内容は2026年6月時点の情報に基づいており、最新の状況と異なる場合があります。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"結論\"\u003e結論\u003c/h2\u003e\n\u003cp\u003e先に持ち帰ってほしいポイントを3つに絞ります。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eスクリーンショット＋テキスト指示だけでデザイントーンに合ったワイヤーが生成できる\u003c/strong\u003e（既存画面のピンク×サイドバー構成を再現）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eバリデーション・APIペイロードまで自発的に実装してくれる\u003c/strong\u003eため、エンジニアへの引き継ぎ資料が副産物として得られる\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e「Claudeチャットで仕様を固めてからDesignに渡す」2段階フローが現実的\u003c/strong\u003e。Claude Designは細かい修正の往復には向かない\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"claude-designとは\"\u003eClaude Designとは\u003c/h2\u003e\n\u003cp\u003eClaude Designは2026年4月17日にAnthropicがリリースしたAIデザインツールです（Anthropic Labs製・リサーチプレビュー）。\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://claude.ai\"\u003eclaude.ai\u003c/a\u003e 内の専用ワークスペースとして動作し、\u003cstrong\u003e左ペインのチャットで指示すると、右ペインのライブキャンバスへリアルタイムに反映される2画面構成\u003c/strong\u003eが特徴です。なお、これは今回試したUIに基づく観察です。公式発表ではインラインコメント・直接編集・調整ノブなどのインタラクションが紹介されています。\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://andfactory.co.jp/techblog/images/posts/claude-design-ai-wireframe-practice/claude-design-ui.png\" alt=\"Claude Designの2ペインUI。左にチャット、右にライブキャンバスが並ぶ\" loading=\"lazy\" /\u003e\n\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e項目\u003c/th\u003e\n          \u003cth\u003e内容\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eリリース\u003c/td\u003e\n          \u003ctd\u003e2026年4月17日（Anthropic Labs製・リサーチプレビュー）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e対象プラン\u003c/td\u003e\n          \u003ctd\u003ePro / Max / Team / Enterprise\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eモデル\u003c/td\u003e\n          \u003ctd\u003eClaude Opus 4.7（デフォルト）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eできること\u003c/td\u003e\n          \u003ctd\u003eプロトタイプ・スライド・ワンページャー・HTML書き出し\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eトークン\u003c/td\u003e\n          \u003ctd\u003e通常チャットとは別枠（プランにより異なる）\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch3 id=\"アクセス方法\"\u003eアクセス方法\u003c/h3\u003e\n\u003cp\u003e\u003ca href=\"https://claude.ai\"\u003eclaude.ai\u003c/a\u003e にログイン後、左サイドバーの「Design」から起動できます。Pro / Max / Team / Enterpriseプランが対象です。\u003c/p\u003e\n\u003cp\u003eNotionコネクタを使う場合は事前設定が必要です。Settings → IntegrationsからNotionを接続しておくと、Design内でNotionのURLをそのまま渡して仕様ページを直接読み込めます。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"実践管理画面のワイヤーを作る\"\u003e実践：管理画面のワイヤーを作る\u003c/h2\u003e\n\u003cp\u003e概要が掴めたところで、実際に試した内容を紹介します。\u003c/p\u003e\n\u003ch3 id=\"最初に渡したプロンプト\"\u003e最初に渡したプロンプト\u003c/h3\u003e\n\u003cp\u003e今回は自社サービスの管理画面に「施策管理ページ」を追加する想定でワイヤーを作成しました。最初のプロンプトはシンプルにしました。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cdiv style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\n\u003ctable style=\"border-spacing:0;padding:0;margin:0;border:0;\"\u003e\u003ctr\u003e\u003ctd style=\"vertical-align:top;padding:0;margin:0;border:0;\"\u003e\n\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e1\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e2\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e3\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e4\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e5\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e6\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e7\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/td\u003e\n\u003ctd style=\"vertical-align:top;padding:0;margin:0;border:0;;width:100%\"\u003e\n\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eXXXXXという管理画面の施策管理ページを作りたい。\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- PC向け管理画面\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- ピンク（#E91E8C）のヘッダー＋左サイドバー構成\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- 施策一覧テーブル（ID・施策名・開始日・終了日・URL・ステータス）\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- 詳細ボタン → モーダルで編集\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- モーダル内はアコーディオンで項目を折りたたむ\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- ステータスは「下書き / 公開 / 非公開」の3種\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/table\u003e\n\u003c/div\u003e\n\u003c/div\u003e\u003cp\u003eこれだけでClaude Designから\u003cstrong\u003e10問のヒアリングが返ってきて\u003c/strong\u003e、設計が始まりました。\u003c/p\u003e","title":"AIデザインツール「Claude Design」を実案件で試してみた ── ワイヤーフレームから仕様ドキュメントまで自動生成"},{"content":"はじめに こんにちは。and factory フロントエンドエンジニアの青木です。\n現在関わっているプロジェクトのフロントエンドで採用している、OpenAPI を Single Source of Truth とした型安全 API 統合パターン を紹介します。\nOrvalでやっていることを並べると次のとおりです。\nバックエンドの make openapi で生成されたYAMLをフロント側にコピー 1コマンドで 型定義 / TanStack Query Hook / Zodスキーマ / MSWモック までを一括生成 公開APIと認証APIで operationId 単位に mutator を切り替え、誤用を生成時点で防ぐ Zodの 二段防御 (coerce × transform) で unknown をコンポーネント層に到達させない 前提: Single Source of Truth とは Single Source of Truth (SSoT) は、ある情報の 「正しい定義を置く場所を 1 箇所に固定する」 設計原則です。日本語では「信頼できる唯一の情報源」と訳されます。\n例えばAPIの「リクエスト/レスポンスの形」をプロジェクトのあちこちに書いていると、以下の問題が起きます。\nバックエンドが型を変えたのにフロントのTypeScript型が古いまま動いてしまう ドキュメントとコードがズレて、新規参画者がどちらを信じればよいか分からなくなる 同じ情報を複数箇所で手書きするため、変更時の修正漏れが必ず発生する これを防ぐため、「APIの形」を openapi.yaml という1つのファイルに集約 しています。フロントの型・Hook・Zod・MSWモックは、すべてそこから自動生成する運用です。\nflowchart TD A[\u0026#34;openapi.yaml\u0026lt;br/\u0026gt;（唯一の「真実」）\u0026#34;]:::source A --\u0026gt; B[TypeScript 型] A --\u0026gt; C[TanStack Query Hook] A --\u0026gt; D[Zod 検証スキーマ] A --\u0026gt; E[MSW モックハンドラ] classDef source fill:#0ea5e9,color:#fff,stroke:#0369a1,stroke-width:2px これが「OpenAPIをSingle Source of Truthにする」の意味です。openapi.yaml を更新して pnpm run generate:api を実行すれば、全派生物が機械的に再生成されます。手作業の同期は不要です。\nなぜ Orval を選んだか — バックエンドと並走するため 本プロジェクトのバックエンドはフロントエンドと別チームで開発が進んでおり、プロジェクトの性質上、バックエンドの実装完了を待たずにフロントを先行して進める必要がありました。加えて、以前担当した別プロジェクトではAPIスキーマの一元管理がありませんでした。レスポンス構造を確認するために .proto 定義を直接読みに行ったり、APIを呼び出して目視確認したりする運用を経験していました。\nこれらを踏まえ、「型を手書きで管理しない」「OpenAPI を契約として先に固め、フロントはモックで動かしながら先行実装する」 の両方を満たす運用方針を立てました。これを支える生成ツールとしてOrvalを採用しています。決め手になったのは次の3点です。\n1. 「OpenAPI 契約 → フロント着手」の成立 バックエンドチームと「このURLでこの形のレスポンスを返す」というスキーマだけ合意できれば、openapi.yaml を起点にOrvalで 型 / Hook / モック が一気に揃います。バックエンド実装が間に合わなくても、フロントは「Orvalが生成した useXxx() を呼び出す」コードを書き進められます。\n2. MSW 自動生成による「未実装 API」のローカル動作確認 mock: { type: 'msw', useExamples: true, generateEachHttpStatus: true } の組み合わせがよく効きます。\nOpenAPIの examples をそのままMSWのレスポンスに流せる responses に定義した4xx/5xxのモックも生成される（デフォルトは200のみ、定義したステータス分だけ生成） エラー UIのテストもバックエンドなしで完結する 結果として バックエンド実装と並走でフロント完成度を上げられる 軽量な openapi-typescript + openapi-fetch ではMSWハンドラを手書きする必要があります。KubbやHey APIはMSWプラグイン等で同等のことが可能ですが、方針が異なりOrvalほど一括では生成しづらい構成です。\n3. バックエンド更新時の「ズレ検出」の安全性 並走開発で最も怖いのは「OpenAPIを更新したのにフロントが古い型のまま気付かない」事故です。Orvalの clean: true で 再生成時に死んだファイル・古い型を完全に削除 するため、operationIdのリネームや削除があれば確実にビルドエラーになります。手書きクライアントでは検知できない契約のズレを、生成段階で機械的に防げます。\n比較した他候補 なお、本プロジェクトのバックエンドは TypeScript ではなく Go で実装 されています。そのため、TypeScriptの型を直接共有するアプローチ（tRPC / Hono RPCなど）は最初から候補に入りませんでした。OpenAPIを中継するアプローチの中から比較した結果は以下の通りです。\n候補 不採用の理由 tRPC / Hono RPC バックエンドが Go のため、TypeScript の型共有が成立しない openapi-typescript + openapi-fetch 型生成のみで Zod / MSW を別途用意する必要があり、フロント先行のセットアップコストが上がる Kubb / Hey API 機能的には近いが、operationId 単位の mutator override で公開/認証 API を生成時に分離できる Orval の運用実績を優先した Orval採用の最大の理由は、TanStack Query・MSW・Zod を併用するチームに必要なものが1コマンドで一括生成できる点でした。バックエンド完成前のフロント先行開発を前提としていたため、初期セットアップで手間が省けた効果は大きかったです。\n技術スタック（API 関連のみ抜粋） カテゴリ 採用技術 Framework Next.js 16 (App Router) / React 19 / TypeScript 5 データ取得 TanStack Query 5 API クライアント生成 Orval 7 バリデーション Zod 3 モック MSW 2 認証 Better Auth ランタイム Node.js 24.11.1 パッケージマネージャ pnpm 9.15.9 生成パイプラインの全体像 flowchart TD A[\u0026#34;backend/.../openapi.yaml\u0026lt;br/\u0026gt;（make openapi）\u0026#34;]:::backend A --\u0026gt;|コピー| B[\u0026#34;src/openapi/openapi_gen.yaml\u0026#34;]:::frontend B --\u0026gt;|pnpm run generate:api| C[\u0026#34;src/gen/\u0026#34;]:::generated subgraph G [生成物] direction TB M[\u0026#34;models/ — 型定義\u0026#34;] R[\u0026#34;repository/ — TanStack Query\u0026#34;] R --\u0026gt; T1[\u0026#34;item.ts\u0026lt;br/\u0026gt;(useXxx / prefetchXxx)\u0026#34;] R --\u0026gt; T2[\u0026#34;item.zod.ts\u0026lt;br/\u0026gt;(URL/Body 検証 Zod)\u0026#34;] R --\u0026gt; T3[\u0026#34;item.msw.ts\u0026lt;br/\u0026gt;(MSW モック)\u0026#34;] end C --\u0026gt; M C --\u0026gt; R G --\u0026gt;|afterAllFilesWrite| F[\u0026#34;biome check --write src/gen\u0026lt;br/\u0026gt;（自動整形）\u0026#34;]:::tool classDef backend fill:#f59e0b,color:#fff,stroke:#b45309,stroke-width:2px classDef frontend fill:#0ea5e9,color:#fff,stroke:#0369a1,stroke-width:2px classDef generated fill:#10b981,color:#fff,stroke:#047857,stroke-width:2px classDef tool fill:#8b5cf6,color:#fff,stroke:#6d28d9,stroke-width:2px 1コマンドで「型 / Hook / Zod / MSWモック / 整形」まで完結する バックエンド開発と並走でフロント先行開発が可能（MSWモック自動生成のため） 公開APIと認証APIで operationId 単位に mutator を切り替える orval.config.ts の構造 orval.config.ts には appApi（TanStack Query 用）と appApiZod（Zod 用）の2つの export があります。同じYAMLから2系統の生成物を作るパターンです。\nTanStack Query Hook と MSW モック (appApi) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 appApi: { input: { target: \u0026#39;./src/openapi/openapi_gen.yaml\u0026#39;, filters: { mode: \u0026#39;exclude\u0026#39;, tags: [\u0026#39;external\u0026#39;, \u0026#39;experimental\u0026#39;], // 外部連携などフロント不要のスキーマは除外 }, }, output: { target: \u0026#39;./src/gen/repository\u0026#39;, schemas: \u0026#39;./src/gen/models\u0026#39;, client: \u0026#39;react-query\u0026#39;, httpClient: \u0026#39;fetch\u0026#39;, mode: \u0026#39;tags-split\u0026#39;, // タグ単位でファイル分割 clean: true, // 再生成時に既存ファイルをクリア mock: { type: \u0026#39;msw\u0026#39;, useExamples: true, generateEachHttpStatus: true, // 4xx/5xx のモックも生成 }, override: { mutator: { path: \u0026#39;./src/lib/custom-fetch.ts\u0026#39;, name: \u0026#39;customFetch\u0026#39;, }, operations: { /* 後述 */ }, }, }, hooks: { afterAllFilesWrite: \u0026#39;biome check --write src/gen\u0026#39;, }, }, 特に効くのは次の3つです。\nmode: 'tags-split' — OpenAPIの tags 単位でドメインフォルダが切られる（item/, user/, auth/ など） generateEachHttpStatus: true — responses に定義した4xx/5xxもモックされる（デフォルトは200のみ。未定義のステータスは生成されない） clean: true — operationIdのリネーム漏れによる「死んだHook」が残らない Zod スキーマ (appApiZod) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 appApiZod: { output: { fileExtension: \u0026#39;.zod.ts\u0026#39;, client: \u0026#39;zod\u0026#39;, override: { zod: { generate: { param: true, // PathParams 検証 query: true, // QueryParams 検証 header: true, body: true, response: false, // レスポンスは型定義に任せて Zod は不要 }, coerce: { param: [\u0026#39;string\u0026#39;, \u0026#39;number\u0026#39;, \u0026#39;boolean\u0026#39;, \u0026#39;bigint\u0026#39;, \u0026#39;date\u0026#39;], query: [\u0026#39;string\u0026#39;, \u0026#39;number\u0026#39;, \u0026#39;boolean\u0026#39;, \u0026#39;bigint\u0026#39;, \u0026#39;date\u0026#39;], }, }, }, }, }, ここで効くのは次の2点です。\nresponse: false — レスポンス検証をZodにやらせない（バックエンド契約信頼 + バンドル削減） coerce 設定 — ?page=1 という文字列クエリを自動で number に強制変換する。Number('abc') が NaN になっても、z.coerce.number() がNaNを拒否するためsafeParse段階で弾ける 公開 API / 認証 API の分離 — operationId 単位の mutator 切り替え 前提: operationId とは operationId はOpenAPI仕様で 各エンドポイント (path × HTTP メソッド) に付ける一意の識別子 です。\n1 2 3 4 5 6 7 8 # openapi.yaml の例 paths: /v1/items: get: operationId: searchItems # ← これ /v1/items/{id}: get: operationId: getItemDetail Orvalはこの operationId を 生成される関数名や Hook 名にそのまま使います。\noperationId: searchItems → useSearchItems() / searchItems() / prefetchSearchItemsQuery() operationId: getItemDetail → useGetItemDetail() / getItemDetail() / \u0026hellip; 省略するとpath + methodから useGetV1ItemsById のような長く読みにくい名前が自動生成されます。そのため、OpenAPI 側で operationId を明示するのが事実上の前提 です。\nOrval の operations override Orvalの operations overrideを使い、operationId ごとに mutator を差し替えています。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 override: { // デフォルト mutator: { path: \u0026#39;./src/lib/custom-fetch.ts\u0026#39;, name: \u0026#39;customFetch\u0026#39;, }, // 公開エンドポイントのみ別 mutator operations: { healthCheck: { mutator: { path: \u0026#39;./src/lib/custom-public-fetch.ts\u0026#39;, name: \u0026#39;customPublicFetch\u0026#39; } }, register: { mutator: { path: \u0026#39;./src/lib/custom-public-fetch.ts\u0026#39;, name: \u0026#39;customPublicFetch\u0026#39; } }, searchItems: { mutator: { path: \u0026#39;./src/lib/custom-public-fetch.ts\u0026#39;, name: \u0026#39;customPublicFetch\u0026#39; } }, getItemDetail: { mutator: { path: \u0026#39;./src/lib/custom-public-fetch.ts\u0026#39;, name: \u0026#39;customPublicFetch\u0026#39; } }, // ... 計 25 個以上 }, }, customFetch（認証 API 用） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // src/lib/custom-fetch.ts（抜粋） export const customFetch = async \u0026lt;T\u0026gt;(url: string, options: RequestInit): Promise\u0026lt;T\u0026gt; =\u0026gt; { const token = await getAccessToken(); if (!token) throw new Error(\u0026#39;No access token\u0026#39;); // 未ログイン状態のリクエストを即時失敗 const response = await fetch(getUrl(url), { ...options, headers: { ...options.headers, Authorization: `Bearer ${token}` }, }); // 401: 認証切れ → signOut + /signin if (response.status === 401 \u0026amp;\u0026amp; typeof window !== \u0026#39;undefined\u0026#39;) { await signOut(); throw new Error(\u0026#39;Unauthorized: redirecting to sign-in\u0026#39;); } // 403: ボディの code を見てアカウント停止判定 if (response.status === 403 \u0026amp;\u0026amp; typeof window !== \u0026#39;undefined\u0026#39;) { const errorBody = await response.clone().json(); if (errorBody?.code === ACCOUNT_SUSPENDED_CODE) { clearTokenCache(); getGlobalQueryClient()?.clear(); window.location.href = ACCOUNT_SUSPENDED_PATH; } } // ... }; customPublicFetch（公開 API 用） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // src/lib/custom-public-fetch.ts（抜粋） export const customPublicFetch = async \u0026lt;T\u0026gt;(url: string, options: RequestInit): Promise\u0026lt;T\u0026gt; =\u0026gt; { const token = await getAccessToken(); const response = await fetch(getUrl(url), { ...options, headers: { ...options.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), // あれば付ける、なくてもリクエストは続行 }, }); if (!response.ok) throw new ApiError(response.status, response.statusText); return await response.json(); }; 抜粋では customFetch 側が Error、customPublicFetch 側が ApiError を投げており、例外型が不揃いです。実プロジェクトではカスタム例外型（ApiError など）に統一し、上位のエラーハンドラがステータスコードで分岐できるようにしてください。\nなぜこの設計が効くか 切り口 効能 operations の手動リストで mutator を割り当て spec の security と自動同期はされないが、orval.config.ts 1ファイルに割り当てを集約できる 誤って customFetch が当たると即 throw 未ログイン状態で No access token が即 throw され、「公開のはずなのに認証必須扱いになっている」事故をオンボード時に検出できる operations を 1 ファイルで列挙 レビューで「これ公開で大丈夫?」を一覧確認できる if (isLoggedIn) ... else ... を毎Hookで書く実装より、生成段階で分離してしまう ほうが簡潔に書けます。\n生成物の構造（1 ドメイン 3 ファイル） タグ単位で3種類のファイルが生成されます。\nsrc/gen/repository/item/ ├── item.ts # useXxx / prefetchXxx (TanStack Query hooks) ├── item.zod.ts # *QueryParams / *PathParams (Zod スキーマ) └── item.msw.ts # getXxxResponseMock (faker でランダム値生成) item.ts — Hooks 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 生成例（抜粋） export const getSearchItemsUrl = (params?: SearchItemsParams): string =\u0026gt; { /* ... */ }; export const searchItems = async (params?: SearchItemsParams, options?: RequestInit) =\u0026gt; { return customPublicFetch\u0026lt;ItemSearchResponse\u0026gt;(getSearchItemsUrl(params), { method: \u0026#39;GET\u0026#39;, ...options, }); }; export const useSearchItems = ( params?: SearchItemsParams, options?: { query?: UseQueryOptions\u0026lt;...\u0026gt; }, ): UseQueryResult\u0026lt;ItemSearchResponse\u0026gt; =\u0026gt; { /* useQuery で包む */ }; export const prefetchSearchItemsQuery = async ( queryClient: QueryClient, params?: SearchItemsParams, ): Promise\u0026lt;QueryClient\u0026gt; =\u0026gt; { /* queryClient.prefetchQuery */ }; 生成される1セットは以下の通りです。\ngetXxxUrl — URL構築関数 xxx — fetch関数 useXxx — useQuery ラッパ prefetchXxxQuery — queryClient.prefetchQuery ラッパ（SSRで重要、後述） useXxxInfinite — 無限スクロール対応（Orvalの override.query.useInfinite を指定したエンドポイントのみ生成） item.zod.ts — 検証スキーマ 1 2 3 4 5 6 7 export const searchItemsQueryParams = zod.object({ keyword: zod.coerce.string().optional(), page: zod.coerce.number().min(1).default(1), per_page: zod.coerce.number().min(1).max(100).default(20), status: zod.array(zod.enum([\u0026#39;ACTIVE\u0026#39;, \u0026#39;INACTIVE\u0026#39;])).optional(), order: zod.enum([\u0026#39;NEWEST\u0026#39;, \u0026#39;POPULAR\u0026#39;, \u0026#39;PRICE_ASC\u0026#39;]).optional(), }); これを page.tsx 側で使います。\n1 2 const result = searchItemsQueryParams.safeParse(rawSearchParams); if (!result.success) handleZodValidationError(result.error); item.msw.ts — MSW モック 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export const getSearchItemsResponseMock = ( overrideResponse: Partial\u0026lt;ItemSearchResponse\u0026gt; = {} ): ItemSearchResponse =\u0026gt; ({ items: Array.from( { length: faker.number.int({ min: 1, max: 10 }) }, () =\u0026gt; ({ id: faker.number.int(), name: faker.string.alpha({ length: { min: 10, max: 20 } }), // ... }) ), ...overrideResponse, }); export const getSearchItemsMockHandler = (overrideResponse?: ...) =\u0026gt; http.get(\u0026#39;*/v1/items\u0026#39;, () =\u0026gt; HttpResponse.json(getSearchItemsResponseMock(overrideResponse))); Zod の二段防御 — coerce × transform URLパラメータの安全性は 「生成 Zod (coerce)」と「ページ側 Zod (transform)」の2層 で担保しています。\n1 段目: Orval 生成の coerce 1 2 // item.zod.ts（自動生成） page: zod.coerce.number().min(1).default(1), ?page=1 という文字列クエリが自動で number にキャストされます。型レベルで string | undefined が number に正規化されます。\n2 段目: page.tsx 側の transform 1 2 3 4 5 6 7 // src/app/ranking/page.tsx（抜粋） const searchParamsSchema = z.object({ tab: z.string().optional().transform(val =\u0026gt; { if (val \u0026amp;\u0026amp; TAB_VALUES.includes(val as TabValue)) return val as TabValue; return DEFAULT_TAB; // 無効値はデフォルトに正規化 }), }); enum外の値が入っても500にならず、デフォルトタブで描画されます。\nなぜこの組み合わせが強いか 1 段目 (coerce) で型キャスト失敗 ('abc' → NaN) を排除 2 段目 (transform) で「想定外の文字列」を黙って正規化 結果として page.tsx は unknown を引数に取らない — 渡ってくる時点で「正規化済みのSearchParams」と決まる MSW モックの「自動生成 + 手動オーバーライド」二層戦略 OrvalはMSWハンドラを自動生成しますが、自動生成されたfakerベースのモックだけでは シナリオテスト ができません。そこで二層構造を取っています。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // src/mocks/handlers.ts const orvalHandlers: RequestHandler[] = [ ...getItemMock(), // 自動生成 ...getUserMock(), ...getAnnouncementMock(), // ... ]; const originalHandlers: RequestHandler[] = [ customGetCategoriesHandler, // 手書きオーバーライド customGetAnnouncementsHandler, // ... ]; // MSW は「先にマッチしたハンドラ」を使うため、original を先頭に置く export const handlers: RequestHandler[] = [...originalHandlers, ...orvalHandlers]; 役割分担 種類 用途 自動生成 (faker) リスト系の動作確認、データ量や境界値のラフチェック 手書きカスタム バグ再現テスト、特定の displayId だけ違う挙動を返す等 1 2 3 4 5 6 7 8 // 手書きハンドラの例 export const customItemDetailHandler = http.get( `*/v1/items/:displayId`, async ({ params }) =\u0026gt; { const { displayId } = params; return HttpResponse.json(mockData[displayId] ?? defaultMock); } ); 有効化制御 1 2 3 4 5 // src/app/msw-provider.tsx if (env.ENABLE_API_MOCKING) { const { worker } = await import(\u0026#39;@/mocks/browser\u0026#39;); await worker.start({ onUnhandledRequest: \u0026#39;bypass\u0026#39; }); } 環境変数 ENABLE_API_MOCKING=true で開発時のみ起動します。本番では実行されません（バンドルから完全に除去するには process.env.NODE_ENV 等のビルド時定数で分岐し、dead code eliminationが効く形にする必要があります）。\nSSR Prefetch の並列実行 — Promise.all × HydrationBoundary Orvalが prefetchXxxQuery 関数を自動生成するため、page.tsx で 複数 API を並列 prefetch → クライアントにキャッシュごと渡す パターンが楽に書けます。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // src/app/home/page.tsx export default async function Page() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) redirect(\u0026#39;/\u0026#39;); const queryClient = new QueryClient(); // 6 件を並列 prefetch await Promise.all([ prefetchGetUserMeQuery(queryClient), prefetchGetFavoriteItemsQuery(queryClient), prefetchGetItemsByRankingQuery(queryClient, RankingType.TOTAL), prefetchGetItemsByRankingQuery(queryClient, RankingType.SATISFACTION), prefetchGetNewArrivalItemsQuery(queryClient), prefetchSearchItemsQuery(queryClient, { status: ACTIVE_TAB_STATUSES }), ]); return ( \u0026lt;HydrationBoundary state={dehydrate(queryClient)}\u0026gt; \u0026lt;Home /\u0026gt; \u0026lt;/HydrationBoundary\u0026gt; ); } 初期HTMLにJSONが埋め込まれるため、初期描画でキャッシュを即時利用できる ただし staleTime 未指定だとハイドレート直後にバックグラウンド再フェッチが走るので、SSR用途では明示推奨 6件を直列で取ると遅延が積み上がるが、Promise.all で 最遅1件分の時間 に圧縮できる SSR 経路では customFetch の 401/403 処理が走らない 前述の customFetch の401/403リダイレクトは typeof window !== 'undefined' でクライアント側に限定しています。SSRのprefetchはサーバー実行のため、prefetch中に認証エラーが起きてもこの処理は通りません。代わりにページ冒頭の getSession() → redirect('/') で未ログインを早期に遮断しています。SSR認証はゲート関数側、クライアント認証は customFetch 側で責務を分担する設計です。\nPromise.all は fail-fast — 失敗を許容する設計に切り替える 上のコードはあえて Promise.all を使っており、1 件でも reject すると SSR レンダリング全体が失敗 します。1ブロックの取得失敗で初期表示を落としたくない場合は Promise.allSettled か個別 .catch を使い、失敗したクエリだけクライアント側で再フェッチさせます。\n1 2 3 4 5 6 7 8 9 10 11 12 // 失敗を許容したい場合 await Promise.allSettled([ prefetchGetUserMeQuery(queryClient), prefetchGetFavoriteItemsQuery(queryClient), prefetchGetItemsByRankingQuery(queryClient, RankingType.TOTAL), // ... ]); // もしくは個別 .catch await Promise.all([ prefetchGetUserMeQuery(queryClient).catch(() =\u0026gt; {}), // ... ]); どちらを選ぶかは「このページは全APIが揃って初めて成立するか / 一部失敗でもUIが描けるか」で決めます。ホームのように複数ブロックの寄せ集めなら後者、認証必須の集計画面のように1件でも欠けたら不整合になるなら前者が向いています。\nAPI 更新フローと直編集禁止ルール API 更新時 1 2 3 4 5 6 7 # バックエンド側 cd ../backend make openapi # backend 側で openapi.yaml を生成 # フロント側 cp ../backend/.../openapi.yaml ./src/openapi/openapi_gen.yaml pnpm run generate:api 1 2 3 4 // package.json \u0026#34;scripts\u0026#34;: { \u0026#34;generate:api\u0026#34;: \u0026#34;orval --config ./orval.config.ts\u0026#34; } ファイル直編集禁止 src/gen/ 配下は 直接編集禁止 とし、.claude/rules/api.md で明文化しています。常にYAMLから再生成します。clean: true で死んだファイルが残らないため、operationIdのリネームも安全に行えます。\nよくある詰まりどころ pnpm run generate:api の実運用で踏みやすかった落とし穴を4つ挙げます。\n1. YAML パースエラー 最も多いのは openapi.yaml のインデントずれ・タグ未定義です。OrvalはYAMLパーサ経由で読み込むため、エラー時には行番号付きでログが出ます（パーサ実装はバージョン依存のため、行番号の出方には多少の差があります）。\n1 2 $ pnpm run generate:api # 例: YAMLException: bad indentation at line 124, column 5 対処は以下の2ステップです。\n該当行のインデントを上下の階層に揃える tags を参照しているのに定義漏れがないか paths と tags を突き合わせる 2. operationId の重複 Orvalは同一 operationId を2箇所で使っているとビルド時に検出して停止します。Go側で複数のpathに同じIDを割り当てると起きやすい事象です。\n1 # 例: Error: Operation ID \u0026#34;searchItems\u0026#34; is duplicated OpenAPI側で operationId を API単位で一意 になるよう修正します。make openapi を実行するチームと事前に命名規約を合意しておくと事故が減ります。\n3. clean: true による生成物の消失 clean: true を有効にしていると、再生成時に gen/ 配下が一旦全削除 されます。誤って gen/ 配下に手書きユーティリティを置いてしまうと、次の再生成で消えます。\n対処は以下の通りです。\ngen/ 配下を 絶対に手で編集しない ルールを徹底する (.claude/rules/api.md で明文化) 共通ユーティリティは src/lib/ など別ディレクトリに置く 4. mutator のパス指定ミス orval.config.ts の mutator.path は 設定ファイルからの相対パス で書きます。プロジェクトのモノレポ化・ディレクトリ移動の際に壊れやすいため、生成後に src/gen/repository/item/item.ts 冒頭のimport文を一度確認してください。\n1 2 // 期待通り: 相対パスで src/lib/custom-fetch を import import { customFetch } from \u0026#39;../../../lib/custom-fetch\u0026#39;; tsconfig.paths aliasやdynamic import経由の場合、import先の誤りを 型解決・バンドル時に検出できず、ランタイムエラーで初めて発覚する ことがあります。原因追跡で時間を要するため注意してください。\nまとめ このプロジェクトの「型安全API統合」を支える5つの工夫を整理します。\nOpenAPI を Single Source of Truth — make openapi のYAMLをフロントにコピーし、Orvalで型・Hook・Zod・MSWを一括生成 二重 Mutator 戦略 — customFetch と customPublicFetch をoperationId単位で配置。specの security と自動同期する仕組みではないが、誤割り当ては即throw＋レビューで検出する Zod の二段防御 — coerce で型キャスト、transform で値正規化。unknown がコンポーネント層まで到達しない MSW の「自動生成 + 手動オーバーライド」 — fakerでランダム生成しつつ、特定シナリオは手書きハンドラで上書き SSR Prefetch の並列実行 — Promise.all で6件並列 + HydrationBoundary で初期表示のAPIラウンドトリップが事実上ゼロ pnpm run generate:api 1コマンドで「型・Hook・Zod・MSWモック・整形」まで終わるため、バックエンドのAPI変更にもフロントが素早く追従できる構造です。\n参考リンク Orval 公式ドキュメント OpenAPI Specification TanStack Query Zod Mock Service Worker (MSW) Better Auth Next.js App Router ","permalink":"https://andfactory.co.jp/techblog/posts/type-safe-api","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eこんにちは。and factory フロントエンドエンジニアの青木です。\u003c/p\u003e\n\u003cp\u003e現在関わっているプロジェクトのフロントエンドで採用している、\u003cstrong\u003eOpenAPI を Single Source of Truth とした型安全 API 統合パターン\u003c/strong\u003e を紹介します。\u003c/p\u003e\n\u003cp\u003eOrvalでやっていることを並べると次のとおりです。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eバックエンドの \u003ccode\u003emake openapi\u003c/code\u003e で生成されたYAMLをフロント側にコピー\u003c/li\u003e\n\u003cli\u003e1コマンドで \u003cstrong\u003e型定義 / TanStack Query Hook / Zodスキーマ / MSWモック\u003c/strong\u003e までを一括生成\u003c/li\u003e\n\u003cli\u003e公開APIと認証APIで \u003cstrong\u003eoperationId 単位に mutator を切り替え\u003c/strong\u003e、誤用を生成時点で防ぐ\u003c/li\u003e\n\u003cli\u003eZodの \u003cstrong\u003e二段防御 (coerce × transform)\u003c/strong\u003e で \u003ccode\u003eunknown\u003c/code\u003e をコンポーネント層に到達させない\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"前提-single-source-of-truth-とは\"\u003e前提: Single Source of Truth とは\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eSingle Source of Truth (SSoT)\u003c/strong\u003e は、ある情報の \u003cstrong\u003e「正しい定義を置く場所を 1 箇所に固定する」\u003c/strong\u003e 設計原則です。日本語では「信頼できる唯一の情報源」と訳されます。\u003c/p\u003e\n\u003cp\u003e例えばAPIの「リクエスト/レスポンスの形」をプロジェクトのあちこちに書いていると、以下の問題が起きます。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eバックエンドが型を変えたのにフロントのTypeScript型が古いまま動いてしまう\u003c/li\u003e\n\u003cli\u003eドキュメントとコードがズレて、新規参画者がどちらを信じればよいか分からなくなる\u003c/li\u003e\n\u003cli\u003e同じ情報を複数箇所で手書きするため、変更時の修正漏れが必ず発生する\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eこれを防ぐため、「APIの形」を \u003cstrong\u003e\u003ccode\u003eopenapi.yaml\u003c/code\u003e という1つのファイルに集約\u003c/strong\u003e しています。フロントの型・Hook・Zod・MSWモックは、すべてそこから自動生成する運用です。\u003c/p\u003e\n\u003cpre class=\"mermaid\"\u003eflowchart TD\n    A[\u0026#34;openapi.yaml\u0026lt;br/\u0026gt;（唯一の「真実」）\u0026#34;]:::source\n    A --\u0026gt; B[TypeScript 型]\n    A --\u0026gt; C[TanStack Query Hook]\n    A --\u0026gt; D[Zod 検証スキーマ]\n    A --\u0026gt; E[MSW モックハンドラ]\n    classDef source fill:#0ea5e9,color:#fff,stroke:#0369a1,stroke-width:2px\n\u003c/pre\u003e\n\u003cp\u003eこれが「OpenAPIをSingle Source of Truthにする」の意味です。\u003ccode\u003eopenapi.yaml\u003c/code\u003e を更新して \u003ccode\u003epnpm run generate:api\u003c/code\u003e を実行すれば、全派生物が機械的に再生成されます。\u003cstrong\u003e手作業の同期は不要です\u003c/strong\u003e。\u003c/p\u003e","title":"Orval で組む型安全 API 統合 ── mutator 分離・Zod 二段防御・MSW 二層モックの実践"},{"content":"1. はじめに 私が担当しているサービスのバックエンドでは モジュラーモノリス を採用しています。本記事では、採用に至った判断軸、実際の構成、運用してみての所感を整理します。\n以降の説明は、例として架空の オンラインレッスンプラットフォーム を題材に進めます。クライアントとしてはユーザー向けサイト・講師向けサイト・カスタマーサポート向けサイトを想定します。会員・講師・ポイント・レッスン予約・シフト・レビューといった共通のドメイン領域を、これら複数のクライアントが共有するサービスを思い浮かべてください。\nクライアントごとに独立したバックエンドを並べる構成では、各バックエンドで類似の実装が増えがちです。ドメインの中核（会員管理、ポイント、受講履歴など）は本質的に1つしかないため、修正のたびに複数のリポジトリへ改修を加える必要があります。マイクロサービスとモジュラーモノリスの両方を比較した結果、最終的にモジュラーモノリスを採用する判断に至りました。\n対象読者\n複数のクライアント・チームから利用される中規模のバックエンドを設計し直そうとしている方 マイクロサービス化を検討しているが、本当に必要かを判断したい方 Goの go.work / go.mod でモジュール境界をどう引くかに興味がある方 TL;DR 対象は「ドメインは1つ、クライアントは複数」の構成。マイクロサービスのメリットより、単一トランザクションで処理できる利点のほうが大きかった 「業務モジュール群」と「クライアント別バックエンド」を分離した。業務モジュールは1つのリポジトリ（以降「コアモジュール群リポジトリ」と呼ぶ）の中で account / lesson / point / system などのサブモジュールに分けた。各バックエンドはそれらをGoの require 経由で取り込む モジュール単位で go.mod を切り、go.work で開発時だけ束ねる。本番ビルドではバージョン付きモジュールを取り込むので、依存方向と境界が物理的に強制される 将来マイクロサービス化したくなったら、モジュール単体を切り出しやすい構造にしておく、というのが導入時の合意事項 2. 当時の状況と課題 今回のアーキテクチャ刷新は、運用中のサービスを動かしながら行ったわけではなく、新規サービスの初期設計・実装を進めている途中で方針転換したものです。最初は従来どおり「クライアントごとに独立したバックエンドを並べる」構成で書き始めていました。しかし検討が深まるにつれて「このままリリースすると技術負債が制御できなくなる」と判断し、設計を全面的に見直しました。リリース後に修正するよりも、構築段階で構造を変えるほうが長期的に安いという見積もりです。\n書き始めていた構成を具体的に示すと、次のとおりです。\nユーザー向けサイト用バックエンド・講師向けサイト用バックエンド・カスタマーサポート向けサイト用バックエンドがそれぞれ独立したリポジトリ 共通DBを直接参照する 共通処理は社内パッケージとして切り出していたが、ビジネスロジック層は各リポジトリ内に重複 この構成では、次のような状況が起きていました。\n同じドメインルールが複数箇所に散らばる。「ポイント消費時の残高検証ロジック」のように、ユーザー向けサイトとカスタマーサポート向けサイトの双方から呼び出される処理が両リポジトリで似て非なる実装になりがち 修正の波及がレビューしきれない。テーブル定義を変えると、3〜4リポジトリでマイグレーションと修正をセットで進める必要がある トランザクション境界が曖昧。同じテーブルを別アプリから書き込むため、整合性は実質的にアプリ側の実装規律に依存 「サービスを分ける」方向に振るか、「中身を共通化する」方向に振るかをここで決める必要がありました。\n3. マイクロサービスを比較対象として置いた 最初に検討したのはマイクロサービス化です。会員・講師・ポイント・レッスンをそれぞれ独立したサービスにして、gRPC経由で通信させる構成です。\nマイクロサービスのメリット（私たちのケースで） 責任が分割されるので各サービスのソースコードはシンプルに保てる 将来、特定ドメインだけスケールアウトしたい場合に独立してスケーリングできる マイクロサービスのデメリット（私たちのケースで） 単一トランザクションで処理できない。ポイント残高・予約・受講履歴など同時に整合していないと厳しい業務が多く、課金系で部分失敗が起きた場合の運用設計をすべて自前で用意する必要がある ユーザー名やニックネームでの横断検索が一気に難しくなる。カスタマーサポートから「この名前で会員と講師を横断検索したい」というニーズは日常的にあり、サービス境界をまたいで実装するコストが大きい proto 定義に思いのほか時間がかかる。共通protoリポジトリにpush → 各サービスの go.mod を更新 → 反映、という流れがユースケース追加のたびに発生する そもそも私たちは複数チームで大規模な並列開発をするほどの規模ではない。マイクロサービス本来のメリットである「組織のスケール」が享受しにくい 「単純なモノリス」では戻したくない理由 一方で「全部1つの大きなアプリに戻す」という選択も適切ではありません。前述のとおり、アーキテクチャ刷新前の状態はまさに「重複のあるモノリス的運用」で、ドメインの境界が曖昧なまま規模だけ大きくなることの痛みは身をもって知っていたからです。\nその中間として現実的なのは、1つのデプロイ単位の中で内部を明確に分割するモジュラーモノリスです。\n4. モジュラーモノリスを選んだ判断軸 §2で挙げた課題と、本節で照らし合わせる観点は次のように対応します。\n§2 の課題 決定打となる観点 ドメインルールが複数箇所に散らばる ドメインルールの一元化 修正の波及がレビューしきれない 境界の強制力 / 将来の分割しやすさ トランザクション境界が曖昧 単一トランザクション 会員 × 講師の横断検索が頻出（業務特性） 横断検索のしやすさ 意思決定時に整理した観点をそのまま並べると、次の表のとおりです。\n観点 モノリス（戻す） マイクロサービス モジュラーモノリス ドメインルールの一元化 できる できる（境界はサービス単位） できる 単一トランザクション できる できない できる 横断検索（会員 × 講師など） できる 実装コスト高 できる デプロイの単純さ シンプル 複雑 シンプル 境界の強制力 弱い（実装規律に依存） 強い（ネットワーク境界） 中（仕組みで担保可能） 将来の分割しやすさ 低い -（既に分割済み） 高い 障害分離 弱い 強い 弱い 学習コスト 低い 高い 中（チームに初めての概念） 「単一トランザクションで処理できる」「横断検索が普通のSQLで書ける」 という現実的な利点が、私たちの業務には決定打になりました。マイクロサービスのメリット（独立スケール、障害分離、組織スケール）よりも、業務トランザクションの整合性をシンプルに記述できるほうが、私たちのチームサイズと業務特性では圧倒的に価値が高いという判断です。\n「将来本当に独立スケールが必要になったら、1モジュールだけ切り出してマイクロサービス化する」という移行余地を確保できるのも、モジュラーモノリスを選ぶ理由の1つです。\n5. 実際の構成 最終的に落ち着いた構成は次のとおりです。まずは全体像を示します。\nflowchart TB subgraph Clients[\u0026#34;クライアント別バックエンド（各リポジトリ）\u0026#34;] direction LR UB[\u0026#34;ユーザー向けサイト用\u0026lt;br/\u0026gt;バックエンド\u0026#34;] TB[\u0026#34;講師向けサイト用\u0026lt;br/\u0026gt;バックエンド\u0026#34;] CB[\u0026#34;カスタマーサポート\u0026lt;br/\u0026gt;向けサイト用\u0026lt;br/\u0026gt;バックエンド\u0026#34;] end Core[\u0026#34;\u0026lt;b\u0026gt;コアモジュール群リポジトリ\u0026lt;/b\u0026gt;（モジュラーモノリス本体）\u0026lt;br/\u0026gt;\u0026lt;br/\u0026gt;account ・ lesson ・ point ・ system\u0026lt;br/\u0026gt;query ・ orchestrator ・ shared\u0026#34;] Clients --\u0026gt;|\u0026#34;go.mod require\u0026#34;| Core 各クライアントバックエンドは、自分が必要なコアモジュールを go.mod の require で取り込みます。実態としてはどのクライアントも横断的に多くのモジュールを使うので、矢印を全部引くと網の目になります。「クライアントは必要に応じて任意のコアモジュールを取り込む」と理解しておけば十分です。\nコアモジュール群の内部は4つの役割で構成されており、それぞれ「他モジュールへのアクセス可否」のルールが異なります。\nflowchart TB ORC[\u0026#34;\u0026lt;b\u0026gt;orchestrator\u0026lt;/b\u0026gt;\u0026#34;] subgraph Business[\u0026#34;\u0026lt;b\u0026gt;ビジネスロジックモジュール\u0026lt;/b\u0026gt;（相互アクセス不可）\u0026#34;] direction LR ACC[\u0026#34;account\u0026#34;] LSN[\u0026#34;lesson\u0026#34;] PT[\u0026#34;point\u0026#34;] SYS[\u0026#34;system\u0026#34;] end QRY[\u0026#34;\u0026lt;b\u0026gt;query\u0026lt;/b\u0026gt;\u0026#34;] SHR[\u0026#34;\u0026lt;b\u0026gt;shared\u0026lt;/b\u0026gt;\u0026#34;] ORC --\u0026gt;|\u0026#34;usecase 呼び出し\u0026#34;| Business QRY -.-\u0026gt;|\u0026#34;SQL read\u0026#34;| Business Business --\u0026gt; SHR QRY --\u0026gt; SHR ORC --\u0026gt; SHR 整理すると次のとおりです。\n役割 他モジュールのコード 他モジュールのテーブル shared ビジネスロジック（account / lesson / point / system） 不可 不可 可 query 不可 read のみ可 可 orchestrator 可（usecase 経由） 不可 可 shared 不可 不可 -（自身） ポイントは、業務ロジックを集約したコアモジュール群リポジトリと、クライアント別バックエンドを分離したことです。\n5.1 業務モジュール: コアモジュール群リポジトリ コアモジュール群リポジトリは、ドメインで分けたサブモジュールを並べた単一リポジトリです。\n1 2 3 4 5 6 7 8 9 core/ ├── account/ # 会員・講師・管理者などのアカウント ├── lesson/ # レッスン・講師詳細・シフト・予約・レビュー ├── point/ # 購入・残高・消費・有効期限 ├── system/ # キャンペーン・お知らせ・メール・SMS・ログ ├── query/ # 横断的な一覧取得・検索用 read 専用層 ├── orchestrator/ # 複数モジュールを束ねるワークフロー層 ├── shared/ # 認証・ログ・AWS・DB フィルタなどの基盤 └── go.work サブモジュールごとに go.mod を持ち、独立した Go モジュールとして公開します。開発時は go.work で束ねてIDEが一括解決できるようにしています。一方、各クライアントバックエンドからは go.mod の require でバージョン付きで取り込みます（後述）。\n各モジュールの内部はClean Architecture + DDDの構造で統一しています。\n1 2 3 4 5 6 7 8 9 10 {module}/ ├── domain/model/ # エンティティ・値オブジェクト ├── usecase/ # ユースケース層（外向き API） │ ├── input/ # 入力 DTO（自動生成バリデーション付き） │ └── output/ # 出力 DTO ├── internal/ │ ├── domain/ # ドメインサービス・リポジトリ IF │ └── infra/ # DB・AWS・外部 API などの実装 ├── di/ # DI 設定 └── migrations/ # Goose マイグレーション ポイントはGoの internal パッケージを使って、モジュール外部からはユースケース層（と DTO）しか見えないよう、物理的に閉じていることです。リポジトリ実装や内部ドメインサービスは internal 以下にあるので、他モジュール・他リポジトリからはimport不可能です。\n5.2 クライアント別バックエンド 各バックエンドリポジトリは、自分が必要なモジュールだけを go.mod で取り込みます。たとえばユーザー向けサイト用バックエンドの go.mod は以下のとおりです。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 module github.com/example/user-site/app go 1.26.3 require ( github.com/example/core/account v1.4.2 github.com/example/core/lesson v1.7.0 github.com/example/core/point v1.2.1 github.com/example/core/query v1.3.0 github.com/example/core/orchestrator v1.1.0 github.com/example/core/shared v1.8.3 github.com/example/core/system v1.0.5 // ... ) バックエンド側の責務は、入口のハンドラーでリクエストを受け、Scenario層で各モジュールのユースケースを呼び出して結果を返すことだけです。HTTP APIならGin + OpenAPI、バッチならAWS Lambdaなど、入口ごとにハンドラーを使い分けます。バックエンド自身にユースケース層は持たせず、ビジネスロジックはすべてモジュール側にあります。Scenario層の役割は6.1で詳しく説明します。\n6. モジュール境界の引き方と内部通信 モジュラーモノリスの本質は、「名ばかりモジュラー」にしないことです。境界を引いただけで実態がスパゲッティ化していると、モノリスの欠点と相互運用コストだけが残ります。私たちはモジュール境界を以下のように引いています。\n全体像を先に示すと、APIリクエストはScenarioを起点に次の経路で処理されます。\nflowchart TB H[\u0026#34;Handler\u0026lt;br/\u0026gt;（Gin + OpenAPI）\u0026#34;] S[\u0026#34;\u0026lt;b\u0026gt;Scenario\u0026lt;/b\u0026gt;\u0026lt;br/\u0026gt;クライアントバックエンド側\u0026lt;br/\u0026gt;BEGIN / COMMIT を張る\u0026#34;] subgraph Core[\u0026#34;コアモジュール群\u0026#34;] O[\u0026#34;\u0026lt;b\u0026gt;orchestrator\u0026lt;/b\u0026gt;\u0026lt;br/\u0026gt;（複数モジュールの write 合成）\u0026#34;] subgraph Mods[\u0026#34;ビジネスロジックモジュール\u0026#34;] direction LR U1[\u0026#34;lesson.usecase\u0026#34;] U2[\u0026#34;point.usecase\u0026#34;] U3[\u0026#34;account.usecase\u0026#34;] end Q[\u0026#34;\u0026lt;b\u0026gt;query\u0026lt;/b\u0026gt;\u0026lt;br/\u0026gt;（横断 read 専用）\u0026#34;] end T[(\u0026#34;ビジネスロジック\u0026lt;br/\u0026gt;モジュールのテーブル\u0026#34;)] H --\u0026gt; S S --\u0026gt;|\u0026#34;複数モジュールにまたがる write\u0026#34;| O S --\u0026gt;|\u0026#34;単一モジュールの write / 単純な read\u0026#34;| Mods S --\u0026gt;|\u0026#34;横断 read\u0026#34;| Q O --\u0026gt; Mods Mods --\u0026gt; T Q -.-\u0026gt;|\u0026#34;SELECT のみ\u0026#34;| T トランザクション境界はScenarioが張り、orchestratorは複数モジュールへの書き込みを合成するだけでトランザクションを持ちません。queryはread専用で、他モジュールのテーブルに対して SELECT のみ許可しています。以降、Scenario・query・orchestratorの3つの役割を順に詳しく見ていきます。\n6.1 API ロジックはバックエンドのScenario層で組み立てる Scenario層という呼び方と「複数モジュールのユースケースを業務単位でまとめる」発想は、Finatextの事例を踏襲しています。\nモジュール間で直接通信することは、原則として認めていません。account から lesson のユースケースを呼ぶ、といった横の依存を許すと、結局モジュール境界が崩れていくからです。例外は「複数モジュールにまたがるワークフローを共通化したい」ケースで、これは orchestrator モジュールに集約します（6.3で後述）。\n同じ理由で、他モジュールのテーブルへ直接アクセスすることも認めていません。各モジュールは自分が管轄するテーブル群を持ち、書き込みは必ず自モジュールのユースケース経由になります。例外的に「複数モジュールにまたがるread」は query モジュールに集約します（6.2で後述）。\n代わりに、API ごとのロジックは各クライアントバックエンドのScenario層で組み立てる 構造にしています。\nバックエンド側にユースケース層は置かない 代わりにScenario層が、複数モジュールのユースケースを呼び出して API のロジックを構築する 役割を担う Clean Architecture上はモジュール側の usecase/ と同じ役割だが、レイヤーを区別するためにバックエンド側は scenario/ という名前にしている リクエストの流れはHandler → Scenario → 各モジュールのUsecase → 各モジュールのDomain / Infraです。Scenarioからモジュールの internal 配下に直接アクセスすることはしません。Goの internal パッケージ規約で物理的にも禁止されています。\nトランザクション境界の管理もScenario層の責務です。複数モジュールにまたがるユースケース呼び出しでは、Scenarioが BEGIN ... COMMIT を張ります。その内側で各モジュールのユースケースを順に呼び出します。各モジュールのユースケースに tx を明示的に引き渡すことで、Scenarioが張ったトランザクションは各モジュールのリポジトリ呼び出しにそのまま引き継がれます。「ポイントは消費されたがレッスン予約は失敗した」のような部分整合崩れを起こさずに済みます。\nDIコンテナーでモジュール側のユースケースのインスタンスを束ねており、バックエンド起動時に必要なユースケースをScenario層へ注入します。\n具体的に書き起こすと、レッスン予約とポイント消費を1つのトランザクションで扱うScenarioは次のとおりです。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 // app/scenario/lesson_booking.go package scenario import ( \u0026#34;context\u0026#34; \u0026#34;database/sql\u0026#34; lessoninput \u0026#34;github.com/example/core/lesson/usecase/input\u0026#34; lessonusecase \u0026#34;github.com/example/core/lesson/usecase\u0026#34; pointinput \u0026#34;github.com/example/core/point/usecase/input\u0026#34; pointusecase \u0026#34;github.com/example/core/point/usecase\u0026#34; ) type BookLessonInput struct { UserID int64 LessonID int64 } type LessonBooking struct { DB *sql.DB Lesson lessonusecase.Booking Point pointusecase.Consumption } func (s LessonBooking) Book(ctx context.Context, in BookLessonInput) error { tx, err := s.DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // Commit 済みなら no-op booking, err := s.Lesson.Create(ctx, tx, lessoninput.CreateBooking{ UserID: in.UserID, LessonID: in.LessonID, }) if err != nil { return err } if err := s.Point.Consume(ctx, tx, pointinput.Consume{ UserID: in.UserID, Amount: booking.RequiredPoint, }); err != nil { return err } return tx.Commit() } LessonBooking 構造体のフィールドが2つのモジュール（lesson / point）のユースケースで構成されており、DIコンテナーがここに具体的な実装を注入します。tx を各ユースケースに引き渡すことで Lesson.Create と Point.Consume が同じトランザクション上で実行されます。どちらかが失敗すれば defer tx.Rollback() で両方の書き込みが巻き戻ります。両方が成功した場合のみ最後の tx.Commit() で確定するという、Goの標準ライブラリだけで書けるシンプルな構造です。\nなお、上記コード例ではScenarioから各モジュールのユースケースに *sql.Tx を直接渡していますが、これは説明を簡潔にするための簡略化です。厳密にはClean Architectureの依存方向ルールから外れる構造で、ユースケース層が database/sql というインフラ詳細を直接参照する形になっています。実装ではトランザクションを表す抽象を挟むことでこの結合を切っており、コード例ではその抽象を省略しています。\n6.2 横断クエリは query モジュールに集約する 会員と講師をJOINするような「複数モジュールにまたがるread」を、各モジュールに散らすと境界が崩れます。私たちは query という read 専用モジュールを1つ用意して、そこに横断クエリを集約しました。\nwriteを各モジュールの境界に閉じ込め、モジュールをまたぐreadだけ query モジュールに集約するという非対称な分け方です。モジュール内に閉じたreadは各モジュールのユースケースで実装します。CQRSの発想に近い設計と言えます。共用DBを採用しているため、境界をまたぐJOINも物理的には書けてしまいます。この自由度を「モジュールをまたぐreadは query に閉じる」ルールで縛り、境界が崩れないようにしています。\n6.3 複数モジュールを束ねる処理は orchestrator に置く 実はこの orchestrator モジュールは、最初は存在していませんでした。Scenario層が各モジュールのユースケースを直接組み合わせれば十分だと考えていたためです。\nしかし開発が進むにつれて、まったく同じ一連のユースケースの流れを複数のバックエンドで実装するケースが増えていきました。たとえば「レッスン予約 → ポイント消費 → 履歴記録」のような流れを、ユーザー向けサイトとカスタマーサポート向けサイトの両方で組み立てる、という状況です。これだと結局、同じワークフローが複数のクライアントバックエンドに散らばります。\nドメインルールが散らばるのを避けるためにモジュラーモノリスを採用したのに、これではワークフローのレベルで同じ問題が再発しかねません。そこで、複数モジュールをまたぐwriteの組み立てを orchestrator モジュール へ集約することにしました。orchestrator は各モジュールのユースケースを呼び出す薄い層で、Scenario側から呼び出されます。\nただし、orchestrator 自体はトランザクション境界を持ちません。実際の BEGIN ... COMMIT を張るのは呼び出し側のScenario層です（6.1を参照）。orchestrator の責務は「複数モジュールにまたがる書き込みの順序やワークフロー合成」のみと決めて、トランザクション制御からは切り離しています。\nこれで、lesson から直接 point のユースケースを呼んだり、その逆を行ったりという横の依存が増殖しないようになっています。\n6.4 go.work と go.mod を二段構えで使う 開発体験と境界の強制を両立する仕組みとして、Go Workspace（go.work）と Go Modules（go.mod）を二段構えで使っています。\n開発時（コアモジュール群リポジトリ内）: go.work で全サブモジュールを束ねる。IDE補完やローカルビルドが効きやすい 本番ビルド時（各バックエンド）: go.mod の require でバージョン付きモジュールを取り込む。go.work は使わない これにより、バージョンを上げるタイミングが明示的になり、コアモジュール群側の破壊的変更がバックエンドに自動で流れ込むことがありません。\nなお go.work 単独運用にはいくつかの落とし穴があるので、本番ビルドは go.mod ベースで通すという基本姿勢は崩していません。\n7. 採用してよかったこと 導入から運用に乗せてみての所感です。\nドメインルールが1箇所にまとまった 最大の効果はこの点でした。「ポイント消費はどのルートでもこのユースケースを通る」「講師の検索条件はこのモジュールに問い合わせる」という境界が明確に引けたので、修正の波及範囲を把握できるようになりました。仕様変更の影響調査にかかる時間が実感として大きく減っています。\n単一トランザクションでシンプルに記述できる Scenario層で BEGIN ... COMMIT を張れるので、予約・ポイント消費周辺の「片方だけ書き込まれて整合が崩れる」リスクをアプリ側で吸収する必要がほぼなくなりました。マイクロサービスにしていたらSagaパターンや補償トランザクションを本格的に検討していたはずで、そのコストを払わずに済んだメリットは大きいです。\n将来マイクロサービス化への移行余地がある 各モジュールが独立した go.mod で公開されている構造です。もし将来「point だけ独立スケールしたい」となった場合、point のusecaseをgRPCサーバーとして公開する形に切り替えれば対応できます。クライアント側をgRPC呼び出しに差し替えるだけでマイクロサービスへ移行できます。境界線がすでに引かれているため、この選択肢を現実的に取り得ます。\n8. 注意点と気をつけていること 良いことばかりではないので、運用して苦労したポイントも共有します。\nモジュール間依存を「ユースケース経由のみ」に保つ ルールとして決めても、急ぎの修正で「ちょっとだけ他モジュールのリポジトリに直接アクセスしたい」という誘惑は出てきます。internal パッケージ規約が支えになっている部分が大きく、規約で物理的に禁止されるため、レビュー時に「これ規約違反になりませんか？」を毎回議論せずに済んでいます。境界を引くなら、レビュー努力ではなく言語機構やリンターに守らせるのが重要だと改めて感じています。\nモジュール間で循環参照を起こさない A → B → A のような循環依存を一度作ってしまうと、go.mod のバージョン解決が破綻します。shared のような最下層モジュールへの依存は許容しつつ、ドメイン間の依存方向は orchestrator を頂点とした一方向にする、という設計を最初に合意しておくと問題を避けられます。\ngo.mod のコンフリクトが頻発する バックエンドはモジュールがバージョンアップするたびに go.mod / go.sum を更新します。複数のPRが並行で進んでいて同じモジュールのバージョンを同時に更新する状況では、本体のコードレビューは通過しているにもかかわらず go.mod だけコンフリクトしてしまうケースが頻発します。開発が活発な時期には、これが無視できないストレス要因です。\n初めての概念で立ち上がりに時間がかかる モジュラーモノリスを採用するのはチームとしても初めてでした。「どこまで分ければよいか」「orchestrator と各モジュールの責務をどう分けるか」については1〜2ヶ月、設計レビューを通じて詳細を固めていきました。最初から完成形を作るのではなく、まずは粗く境界を引いて、運用しながら整えていくスタンスで進めたのが結果的に良かったと感じています。\n1モジュールが落ちると全体が落ちる これはモジュラーモノリスの構造上やむを得ない点で、マイクロサービスのような「一部停止」はできません。業務ドメイン部分の障害分離を見送るというトレードオフを受け入れています。\nトランザクションの分離レベルは業務ごとに設計する §6.1のコード例にある BeginTx(ctx, nil) はデフォルトの分離レベル（PostgreSQLなら READ COMMITTED）で動作します。複数モジュールの整合性が必要な処理では、たとえばレッスン予約とポイント消費の同時実行で残高マイナスを踏むような race condition を起こしえる構造です。本番では sql.TxOptions で分離レベルを上げるか、Point.Consume 内部で SELECT ... FOR UPDATE を使うなど、業務ごとに分離戦略を別途設計しています。\n今後深掘りしたい論点について 紙幅の都合で本記事では踏み込めなかった論点を、別記事で扱う候補として残しておきます。\nマイグレーション運用: 共有DBを採用しているため、複数バックエンドが異なるモジュールバージョンに依存している状況で、マイグレーションの適用順序やロールバック方針をどう設計するかは独立した論点となる。次稿以降で整理したい。 query モジュールの暗黙のスキーマ依存: Goの internal はimport経路を物理的に防げる。一方で query が他モジュールのテーブルに投げる JOIN / SELECT は防げない。他モジュールの内部スキーマ変更で query のSQLが無言で壊れるリスクがあり、契約テストやCIでのスキーマ変更影響チェックの整備は今後の課題である。 9. まとめ 「ドメインは1つ、入口クライアントは複数」のサービスにはモジュラーモノリスが向いていた。マイクロサービスのメリットより、単一トランザクションでシンプルに記述できる利点のほうが大きかった 業務ロジックをコアモジュール群リポジトリのサブモジュール群に集約し、クライアント別バックエンドはそれを require するアダプタに留めた。境界は internal パッケージ規約と go.mod のバージョン管理で物理的に守られる 横断 read は query、複数モジュールの write 取りまとめは orchestrator という分離で、モジュール間の依存が増殖しないようにした 将来マイクロサービス化が必要になったときの移行余地は確保している。最初からマイクロサービスにするよりは、境界を引きつつモジュラーモノリスで始めて、本当に必要になってから切り出すほうが安全だと考えている 同様の規模・特性のサービス（ドメインは限定的だが、入口クライアントが複数あるサービス）でアーキテクチャを検討している方の参考になれば幸いです。\n関連リンク モジュラーモノリスのモジュール間連携（Finatext） Shopifyにおけるモジュラモノリスへの移行（翻訳・Qiita） モジュラモノリス徹底解剖〜実践者から学ぶ Lunch LT〜 Go の internal パッケージを使いこなそう モジュラモノリスのモジュール間通信の話 ","permalink":"https://andfactory.co.jp/techblog/posts/back-end-modular-monolith","summary":"\u003ch2 id=\"1-はじめに\"\u003e1. はじめに\u003c/h2\u003e\n\u003cp\u003e私が担当しているサービスのバックエンドでは \u003cstrong\u003eモジュラーモノリス\u003c/strong\u003e を採用しています。本記事では、採用に至った判断軸、実際の構成、運用してみての所感を整理します。\u003c/p\u003e\n\u003cp\u003e以降の説明は、例として架空の \u003cstrong\u003eオンラインレッスンプラットフォーム\u003c/strong\u003e を題材に進めます。クライアントとしてはユーザー向けサイト・講師向けサイト・カスタマーサポート向けサイトを想定します。会員・講師・ポイント・レッスン予約・シフト・レビューといった共通のドメイン領域を、これら複数のクライアントが共有するサービスを思い浮かべてください。\u003c/p\u003e\n\u003cp\u003eクライアントごとに独立したバックエンドを並べる構成では、各バックエンドで類似の実装が増えがちです。ドメインの中核（会員管理、ポイント、受講履歴など）は本質的に1つしかないため、修正のたびに複数のリポジトリへ改修を加える必要があります。マイクロサービスとモジュラーモノリスの両方を比較した結果、最終的にモジュラーモノリスを採用する判断に至りました。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e対象読者\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e複数のクライアント・チームから利用される中規模のバックエンドを設計し直そうとしている方\u003c/li\u003e\n\u003cli\u003eマイクロサービス化を検討しているが、本当に必要かを判断したい方\u003c/li\u003e\n\u003cli\u003eGoの \u003ccode\u003ego.work\u003c/code\u003e / \u003ccode\u003ego.mod\u003c/code\u003e でモジュール境界をどう引くかに興味がある方\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"tldr\"\u003eTL;DR\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e対象は「ドメインは1つ、クライアントは複数」の構成\u003c/strong\u003e。マイクロサービスのメリットより、単一トランザクションで処理できる利点のほうが大きかった\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e「業務モジュール群」と「クライアント別バックエンド」を分離\u003c/strong\u003eした。業務モジュールは1つのリポジトリ（以降「コアモジュール群リポジトリ」と呼ぶ）の中で \u003ccode\u003eaccount\u003c/code\u003e / \u003ccode\u003elesson\u003c/code\u003e / \u003ccode\u003epoint\u003c/code\u003e / \u003ccode\u003esystem\u003c/code\u003e などのサブモジュールに分けた。各バックエンドはそれらをGoの \u003ccode\u003erequire\u003c/code\u003e 経由で取り込む\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eモジュール単位で go.mod を切り、\u003ccode\u003ego.work\u003c/code\u003e で開発時だけ束ねる\u003c/strong\u003e。本番ビルドではバージョン付きモジュールを取り込むので、依存方向と境界が物理的に強制される\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e将来マイクロサービス化したくなったら、モジュール単体を切り出しやすい構造\u003c/strong\u003eにしておく、というのが導入時の合意事項\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"2-当時の状況と課題\"\u003e2. 当時の状況と課題\u003c/h2\u003e\n\u003cp\u003e今回のアーキテクチャ刷新は、運用中のサービスを動かしながら行ったわけではなく、\u003cstrong\u003e新規サービスの初期設計・実装を進めている途中で方針転換した\u003c/strong\u003eものです。最初は従来どおり「クライアントごとに独立したバックエンドを並べる」構成で書き始めていました。しかし検討が深まるにつれて「このままリリースすると技術負債が制御できなくなる」と判断し、設計を全面的に見直しました。リリース後に修正するよりも、構築段階で構造を変えるほうが長期的に安いという見積もりです。\u003c/p\u003e\n\u003cp\u003e書き始めていた構成を具体的に示すと、次のとおりです。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eユーザー向けサイト用バックエンド・講師向けサイト用バックエンド・カスタマーサポート向けサイト用バックエンドがそれぞれ独立したリポジトリ\u003c/li\u003e\n\u003cli\u003e共通DBを直接参照する\u003c/li\u003e\n\u003cli\u003e共通処理は社内パッケージとして切り出していたが、ビジネスロジック層は各リポジトリ内に重複\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eこの構成では、次のような状況が起きていました。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e同じドメインルールが複数箇所に散らばる\u003c/strong\u003e。「ポイント消費時の残高検証ロジック」のように、ユーザー向けサイトとカスタマーサポート向けサイトの双方から呼び出される処理が両リポジトリで似て非なる実装になりがち\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e修正の波及がレビューしきれない\u003c/strong\u003e。テーブル定義を変えると、3〜4リポジトリでマイグレーションと修正をセットで進める必要がある\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eトランザクション境界が曖昧\u003c/strong\u003e。同じテーブルを別アプリから書き込むため、整合性は実質的にアプリ側の実装規律に依存\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e「サービスを分ける」方向に振るか、「中身を共通化する」方向に振るかをここで決める必要がありました。\u003c/p\u003e\n\u003ch2 id=\"3-マイクロサービスを比較対象として置いた\"\u003e3. マイクロサービスを比較対象として置いた\u003c/h2\u003e\n\u003cp\u003e最初に検討したのはマイクロサービス化です。会員・講師・ポイント・レッスンをそれぞれ独立したサービスにして、gRPC経由で通信させる構成です。\u003c/p\u003e\n\u003ch3 id=\"マイクロサービスのメリット私たちのケースで\"\u003eマイクロサービスのメリット（私たちのケースで）\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e責任が分割される\u003c/strong\u003eので各サービスのソースコードはシンプルに保てる\u003c/li\u003e\n\u003cli\u003e将来、特定ドメインだけスケールアウトしたい場合に独立してスケーリングできる\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"マイクロサービスのデメリット私たちのケースで\"\u003eマイクロサービスのデメリット（私たちのケースで）\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e単一トランザクションで処理できない\u003c/strong\u003e。ポイント残高・予約・受講履歴など同時に整合していないと厳しい業務が多く、課金系で部分失敗が起きた場合の運用設計をすべて自前で用意する必要がある\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eユーザー名やニックネームでの横断検索が一気に難しくなる\u003c/strong\u003e。カスタマーサポートから「この名前で会員と講師を横断検索したい」というニーズは日常的にあり、サービス境界をまたいで実装するコストが大きい\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eproto 定義に思いのほか時間がかかる\u003c/strong\u003e。共通protoリポジトリにpush → 各サービスの \u003ccode\u003ego.mod\u003c/code\u003e を更新 → 反映、という流れがユースケース追加のたびに発生する\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eそもそも私たちは複数チームで大規模な並列開発をするほどの規模ではない\u003c/strong\u003e。マイクロサービス本来のメリットである「組織のスケール」が享受しにくい\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"単純なモノリスでは戻したくない理由\"\u003e「単純なモノリス」では戻したくない理由\u003c/h3\u003e\n\u003cp\u003e一方で「全部1つの大きなアプリに戻す」という選択も適切ではありません。前述のとおり、アーキテクチャ刷新前の状態はまさに「重複のあるモノリス的運用」で、ドメインの境界が曖昧なまま規模だけ大きくなることの痛みは身をもって知っていたからです。\u003c/p\u003e\n\u003cp\u003eその中間として現実的なのは、\u003cstrong\u003e1つのデプロイ単位の中で内部を明確に分割するモジュラーモノリス\u003c/strong\u003eです。\u003c/p\u003e\n\u003ch2 id=\"4-モジュラーモノリスを選んだ判断軸\"\u003e4. モジュラーモノリスを選んだ判断軸\u003c/h2\u003e\n\u003cp\u003e§2で挙げた課題と、本節で照らし合わせる観点は次のように対応します。\u003c/p\u003e","title":"Goで実装するバックエンドのモジュラーモノリス構成"},{"content":"QAフェーズへの移行を機に、後回しにしていたテスト基盤を一気に整備した記録です。\n同じドメインで連携する3つのNext.jsサイト（利用者向け/提供者向け/社内管理画面）を並行開発し、リリース前のQAに突入したものの、モンキーテストで予想を超えるバグが多発しました。\n修正のたびに3サイト全画面を手動で再確認することになり、1サイクルあたり30分〜1時間かかりました。本来テストシナリオ作成に充てるはずだった1週間がバグ修正で埋まりました。\nそこでE2Eテスト（Playwright）・コンポーネントテスト（Vitest）・本番エラー監視（Sentry）を 半日で一気に導入 しました。\nこの記事では、Next.js App Router + TanStack Query + MSW という構成で、ツール選定から実装・CI統合までの過程を解説します。あわせて、SSR / Turbopack固有のハマりポイントと、AIでテストを量産する際の落とし穴も共有します。\nこの記事で得られること Next.js App Routerで Playwright + MSW を動かすためのSSR対応パターン MSW ハンドラーを E2E と Vitest で共有する設計（モックの二重管理を防ぐ） E2E / Vitest / 手動テストの 仕分け基準（テストピラミッドの設計） Sentryを Next.js 16 + Turbopack で動かす際のハマりポイントと解決策 Sentryの 3層防御マスキング設計（SDKフック / Server-side Scrubber / Generative AI機能停止） ソースマップを Sentry にアップロードしない という運用選択肢とそのトレードオフ AIでテストを量産する際の「成功するテストしか作らない」問題と対策 動作確認環境 種別 バージョン OS macOS 15.x Node.js 24.x パッケージマネージャー pnpm 10.x Next.js 16.x（Turbopack） React 19.x @playwright/test 1.58.x vitest 4.x msw 2.x @sentry/nextjs 10.x @tanstack/react-query 5.x 1. 背景: 手動テストだけでは回らなくなった瞬間 3つのNext.jsサイト（利用者向けサイト/提供者向けサイト/社内管理画面）を約10名のチームで半年かけて並行開発しました。\n開発スピードを優先する判断から、フロントエンドの自動テストは整備されないままQAフェーズに突入しました。\n具体的に何が起きていたか：\nモンキーテスト初日に予想を超えるバグが多発 — テストシナリオ作成予定の1週間がバグ修正で埋まる バグ修正のたびに全画面を手動で再確認（1回30分〜1時間）→ 修正→デグレ→再修正のサイクル 3サイトが共通の API を使っているため、1サイトの修正が他サイトに影響していないかの確認が困難 テスト手順が担当者の頭の中にしかなく、品質保証が完全に属人化 AI（Claude Code / Devin等）でコードを生成しているものの、設計品質のチェック不在で手戻りが多発 開発は完了しているのにリリースできない——その最大の理由が「品質保証の仕組みがない」ことでした。\nなぜ QA フェーズで腰を据えてテスト戦略を設計するのか 本来、テスト戦略は開発初期に設計すべきです。今回のプロジェクトでは、フロントエンドの開発スピードを優先する判断から、自動テストの整備は後ろ倒しになっていました。\nQAフェーズに入り、手動テストの限界が明らかになったこのタイミングで、「とりあえずテストを書く」のではなく チームの標準となるテスト戦略を設計する 判断をしました。\n今回設計するテスト戦略は、現行プロジェクトの品質保証だけでなく、次期プロジェクトで開発初日からテストが回る体制を作るための基盤でもあります。\n2. ツール選定: なぜ Playwright × Vitest × Sentry なのか E2E: Playwright を選んだ決定的な理由 E2Eツールは6つの候補（Playwright / Cypress / WebdriverIO / Nightwatch / TestCafe / CodeceptJS）を比較しました。結論は Playwright 一択 です。\n一次選考: Playwright / Cypress 以外を落とした理由 3サイト横断のテスト基盤としてAI連携・SSR対応・運用実績の3軸で評価しました。\nツール 落とした理由 WebdriverIO Selenium ベースで起動・実行が重く、CI 時間が3サイト並列で許容外。MSW 連携の知見も少ない Nightwatch コミュニティ規模が小さく、Next.js App Router × SSR の事例が見つからない。社内に知見保有者なし TestCafe 開発ペースが鈍化し、最新の Next.js / React 19 環境での動作実績が乏しい CodeceptJS ラッパー型で内部は Playwright / WebdriverIO のいずれかに依存。直接 Playwright を採用するほうが抽象化のオーバーヘッドがない この時点で Playwright と Cypress の2択 に絞り込みました。\n二次選考: Playwright が Cypress を上回った決定打 決め手は AI 連携・並列実行のスケーラビリティ・マルチブラウザ対応 の3点です。\n比較軸 Playwright Cypress AI 連携 ◎ MCP Server + Codegen ○ AI 機能（開発中） 並列実行 ◎ 標準で無料（fullyParallel: true） △ 公式 --parallel は Cloud 必須。CI matrix + cypress-split で自前並列は無料可 マルチブラウザ ◎ Chromium/Firefox/WebKit △ Chromium 中心 デバッグ ○ Trace Viewer + UI mode ◎ タイムトラベル（直感的） SSR + MSW 連携 ◎ msw/node をアプリ側で起動 ◎ 同上（ランナー非依存） 並列実行についての補足です。Cypress Cloudが必須なのは公式の --parallel フラグそのものとload-balancing/insight機能だけです。spec分割をCI matrixで自前管理すれば無料でも並列化は可能です（cypress-split やspecファイル分割など）。今回は3サイト横断のメンテコストを抑える目的で、追加実装なしで並列化できるPlaywrightを優先しました。\n3サイト横断のテスト基盤として、Playwrightを採用しました。決め手は次の3点です。第一に、社内で実証済みのAIエージェント連携（Playwright MCP / Codegen）。第二に、追加課金なしでスケールする並列実行。第三に、Chromium/Firefox/WebKitを網羅するマルチブラウザ対応です。Cypressのタイムトラベルデバッグは魅力的でしたが、上記3点の優位を覆すには至りませんでした。\nSSR + MSW は「ランナーの違い」ではなく「msw/node をどこで起動するか」の問題 注意点として、prefetchQuery のようなSSRデータ取得をMSWでモックできるかは、E2EランナーがPlaywrightかCypressか で決まりません。実際には、Next.js dev serverプロセス内に msw/node を起動できるか で決まります。\nサーバー側: msw/node（server.listen()）→ SSR prefetch をインターセプト ✅ ブラウザ側: msw/browser（worker.start()）→ クライアント fetch をインターセプト ✅ 本記事の§5.1で示す構成（msw-provider.tsx で msw/node を起動）は、Cypress側でも成立します。start-server-and-test 等で同じNext.js dev serverを立ち上げる構成にすればよいだけです。ブラウザ側API（cy.intercept() と page.route()）の違いはありますが、SSRモックの可否はランナー非依存です。\n参考実装:\nbillrisher/ssr-mocking（Cypress + Next.js SSR + msw/node） 9sako6/zenn.dev — Testing SSR Next.js with Cypress and MSW コンポーネントテスト: Vitest を選んだ理由 プロジェクトの一部に既存のJestテストがありましたが、jest.mock() でフックを直接モックしており、E2Eで使うMSWとは別世界でした。\n1 2 3 // Jest: フック自体をモック → API の振る舞いは検証できない jest.mock(\u0026#34;@/gen/repository/reservation/reservation\u0026#34;); (useGetReservationList as jest.Mock).mockReturnValue({ isPending: true }); Vitestにすると、E2Eと同じMSWハンドラーをそのまま使えます：\n1 2 3 4 5 6 7 8 9 10 11 12 13 // Vitest + MSW: 実際の HTTP リクエストをインターセプト // → fetch → フック → 描画の全フローを検証 import { server } from \u0026#39;@/mocks/server\u0026#39;; test(\u0026#39;API エラー時にエラー表示になる\u0026#39;, async () =\u0026gt; { server.use( http.get(\u0026#39;*/api/users\u0026#39;, () =\u0026gt; HttpResponse.json({ message: \u0026#39;Error\u0026#39; }, { status: 500 }), ), ); render(\u0026lt;UserList /\u0026gt;); expect(await screen.findByText(\u0026#39;エラーが発生しました\u0026#39;)).toBeInTheDocument(); }); 観点 jest.mock()（既存） MSW（推奨） モック対象 TanStack Query のフック自体 HTTP リクエスト 検証範囲 コンポーネントの描画のみ fetch → フック → 描画の全フロー E2E との整合性 E2E は MSW、単体は jest.mock で別世界 同じハンドラーを共有 リファクタリング耐性 フック名変更で壊れる API エンドポイントが変わらなければ安定 E2E と単体テストでモック基盤を共有できる のが最大の利点です。\nエラー監視: Sentry を選んだ理由 テストでリリース前の品質は担保できますが、本番固有のエラーは必ず発生します。しかし、プロジェクトには 本番エラーを検知する仕組みが一切ない 状態でした。\nError BoundaryはフォールバックUIを表示するだけで、エラーの発生をどこにも通知しません。ユーザーからの問い合わせで初めてバグに気づく、という状況です。\nSentryを選んだ理由：\n@sentry/nextjs がApp Routerをネイティブサポート（instrumentation.ts でSSR/RSCエラーも自動キャプチャ） 既存の react-error-boundary に onError コールバックを追加するだけで統合可能 Web Vitals（LCP / INP / CLS）の自動計測に対応（FIDは2024-03-12にINPへ置換され、Sentry SDK v10でも置換済み） 3. テストピラミッドの設計: 何をどのレイヤーでテストするか 「全ページをE2Eでテストする」のは非効率です。E2Eはテストピラミッドの頂点——最もコストが高いテスト手法です。\nE2E (Playwright) コンポーネント・統合 (Vitest) ユニット (Vitest) 静的解析 (TypeScript / ESLint) 各層の特性は次のとおりです：\n層 実行コスト テスト本数の目安 主なテスト対象 E2E 数十秒〜分/テスト 少（各サイト2〜3本） ページ遷移を伴うクリティカルパス コンポーネント・統合 ミリ秒/テスト 中量 状態遷移・エラーハンドリング ユニット ミリ秒/テスト 多量 Zodスキーマ・hooks・utils 静的解析 即時（保存時 / CI） 全コード 型・構文・コーディング規約 ユースケース一覧から、以下の基準で振り分けました：\nE2E でやること Vitest でやること ページ遷移を伴うユーザーフロー バリデーションの全パターン 認証状態に依存する表示切り替え ローディング・エラー・空状態 API 連携の正常系ハッピーパス API エラー系のハンドリング クリティカルパス（複数ページ横断） Zod スキーマの境界値 例えば「ログインフォームのバリデーション」はVitest、「トップ→一覧→詳細のページ遷移フロー」はE2E、という分担です。\n全ページを最初からE2E化すると、CIの実行時間が伸び、テストが壊れるたびに修正コストが発生します。テストを書く文化を定着させる初期段階でこれをやると 「テスト＝開発を止めるもの」 という認識が定着するリスクもあります。各サイト2〜3本のクリティカルパスからスタートし、段階的に拡充する計画です。\n4. 3サイト横断のテスト基盤統一 3サイトとも Next.js App Router + TanStack Query + MSW という同じ技術スタックのため、テスト基盤を統一しました。同じ設計を別サイトでゼロから作り直すのは無駄です。\n共通構成 1 2 3 4 5 6 7 8 9 10 11 12 13 14 {project-root}/ ├── e2e/ # E2Eテスト（Playwright） │ ├── {page-name}.spec.ts │ └── fixtures/ ├── playwright.config.ts ├── vitest.config.ts # コンポーネント・統合テスト ├── vitest.setup.ts # MSW server.listen() + Testing Library └── src/ ├── mocks/ # MSWハンドラー（E2E / Vitest 共有） └── features/ └── {feature}/ ├── components/ │ ├── {component}.tsx │ └── {component}.test.tsx # コロケーション サイト別の設定差分 項目 利用者向けサイト 提供者向けサイト 管理画面 ビューポート モバイル専用（428×926） PC + モバイル PC + モバイル 認証テスト ユーザーログイン 提供者ログイン 管理者ログイン 優先テスト対象 予約・決済フロー シフト・稼働状態管理 ユーザー管理・設定 設定差分はビューポートと認証フローの違い程度です。テスト基盤（Playwright設定、Vitest設定、MSW構成）はテンプレート化し、サイト間で統一しています。1サイトで構築 → 残り2サイトはテンプレート適用 のフローで横展開できます。\n5. 実装: MSW × Playwright で SSR をテストする ここからはPlaywrightを採用した前提に立ち、§2二次選考で確認した「ランナー非依存のSSR + MSW連携」を具体実装に落としていきます。\n5.1 MSW の2層構造 Next.js App RouterでMSWを使うには、サーバー側とブラウザ側の両方 でMSWを起動する必要があります。\n1 2 3 4 5 // msw-provider.tsx（サーバー側） if (process.env.NEXT_PUBLIC_ENABLE_API_MOCKING === \u0026#34;true\u0026#34;) { const { server } = await import(\u0026#34;@/mocks/server\u0026#34;); server.listen({ onUnhandledRequest: \u0026#34;bypass\u0026#34; }); } 1 2 3 4 5 6 // msw-client-provider.tsx（ブラウザ側） \u0026#34;use client\u0026#34;; const mockingEnabledPromise = process.env.NEXT_PUBLIC_ENABLE_API_MOCKING === \u0026#34;true\u0026#34; \u0026amp;\u0026amp; typeof window !== \u0026#34;undefined\u0026#34; ? import(\u0026#34;@/mocks/browser\u0026#34;).then(({ worker }) =\u0026gt; worker.start({ onUnhandledRequest: \u0026#34;bypass\u0026#34; })) : Promise.resolve(); ブラウザ側だけでMSWを動かすと、SSR時の prefetchQuery が本物のAPIに飛んでしまいます。サーバー側にもMSWを入れることで、SSRでもモックデータが返ります。\nonUnhandledRequest を \u0026quot;bypass\u0026quot; にする理由 dev serverとブラウザ側の両方で onUnhandledRequest: \u0026quot;bypass\u0026quot; を指定しているのは、Next.js内部の /_next/* を素通しする必要があるためです。素通しの対象はHMR・React Server Component fetch・Webpack/Turbopackチャンク取得などです。\u0026quot;error\u0026quot; にするとこれらの内部fetchがすべて未マッチで失敗扱いになり、dev server自体が壊れます。一方、後述する vitest.setup.ts 側はjsdomの純粋な環境です。未マッチfetch＝バグとして \u0026quot;error\u0026quot; でfail-fastさせる方針にしています（§5.3）。\n5.2 MSW ハンドラーを E2E と Vitest で共有する テスト戦略全体で最も重要な設計判断は、E2E テストとコンポーネント・統合テストで MSW ハンドラーを共有する ことです。\n1 2 3 4 5 6 7 8 src/mocks/ ├── handlers.ts ← E2E / Vitest 共通のエントリポイント ├── handlers/ │ ├── auth.ts ← 認証（カスタム） │ ├── user.ts ← ユーザー（カスタム） │ └── ... ├── server.ts ← msw/node（SSR + Vitest 用） └── browser.ts ← msw/browser（E2E ブラウザ側用） 3サイトともOrval（OpenAPIコードジェネレーター）で自動生成されたMSWハンドラーを持っています。カスタムハンドラーを先頭に配置し、自動生成ハンドラーをフォールバックとして使う 構成です。\n1 2 3 4 5 6 // handlers.ts import { customHandlers } from \u0026#34;./handlers/index\u0026#34;; import { orvalHandlers } from \u0026#34;./generated\u0026#34;; // MSW ハンドラーは登録順で先勝ち export const handlers = [...customHandlers, ...orvalHandlers]; この構成により、以下のメリットがあります：\nモックの二重管理が不要: E2E用とVitest用でハンドラーを別々に書く必要がない 正常系はそのまま動く: 既存のOrvalハンドラーがフォールバックとして機能するため、テストごとにモックを準備しなくてよい 異常系だけ上書きする: server.use() でテスト単位でハンドラーを差し替えるだけで異常系テストが書ける 5.3 Vitest 側の MSW セットアップ vitest.setup.ts でMSWサーバーのライフサイクルを管理します。\n1 2 3 4 5 6 7 8 // vitest.setup.ts import \u0026#34;@testing-library/jest-dom/vitest\u0026#34;; import { server } from \u0026#34;@/mocks/server\u0026#34;; import { beforeAll, afterAll, afterEach } from \u0026#34;vitest\u0026#34;; beforeAll(() =\u0026gt; server.listen({ onUnhandledRequest: \u0026#34;error\u0026#34; })); afterEach(() =\u0026gt; server.resetHandlers()); afterAll(() =\u0026gt; server.close()); vitest.config.ts でこのセットアップファイルを読み込みます：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // vitest.config.ts import { defineConfig } from \u0026#34;vitest/config\u0026#34;; import react from \u0026#34;@vitejs/plugin-react\u0026#34;; import path from \u0026#34;path\u0026#34;; export default defineConfig({ plugins: [react()], test: { environment: \u0026#34;jsdom\u0026#34;, setupFiles: [\u0026#34;./vitest.setup.ts\u0026#34;], include: [\u0026#34;src/**/*.test.{ts,tsx}\u0026#34;], }, resolve: { alias: { \u0026#34;@\u0026#34;: path.resolve(__dirname, \u0026#34;./src\u0026#34;), }, }, }); 正常系テストでは server.use() を呼ぶ必要はありません。vitest.setup.ts で起動したMSWサーバーが、Orval自動生成ハンドラーのデフォルトレスポンスを返します。\nSSRエラーハンドリングなど異常系テストでは、テスト単位で server.use() を使ってハンドラーを上書きします：\n1 2 3 4 5 6 7 8 9 10 test(\u0026#39;ランキング API が500を返した場合、エラー表示になる\u0026#39;, async () =\u0026gt; { server.use( http.get(\u0026#39;*/v1/rankings/:type\u0026#39;, () =\u0026gt; HttpResponse.json({ message: \u0026#39;Internal Server Error\u0026#39; }, { status: 500 }), ), ); render(\u0026lt;UserList /\u0026gt;, { wrapper: createWrapper() }); expect(await screen.findByText(\u0026#39;データの取得に失敗しました\u0026#39;)).toBeInTheDocument(); }); 5.4 Playwright 設定: webServer で MSW を起動する Playwright側は webServer オプションでMSW有効のdev serverをテスト実行時に自動起動します。NEXT_PUBLIC_ENABLE_API_MOCKING=true でアプリ側のMSW ProviderをONにするのがポイントです。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 // playwright.config.ts import { defineConfig, devices } from \u0026#34;@playwright/test\u0026#34;; export default defineConfig({ testDir: \u0026#34;./e2e\u0026#34;, fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 1 : undefined, reporter: \u0026#34;html\u0026#34;, use: { baseURL: \u0026#34;http://localhost:3000\u0026#34;, trace: \u0026#34;on-first-retry\u0026#34;, /* モバイルビューポート（デザイン基準幅 428px） */ viewport: { width: 428, height: 926 }, }, projects: [ { name: \u0026#34;chromium\u0026#34;, use: { ...devices[\u0026#34;Desktop Chrome\u0026#34;], viewport: { width: 428, height: 926 } }, }, ], webServer: { command: \u0026#34;NEXT_PUBLIC_ENABLE_API_MOCKING=true pnpm dev\u0026#34;, url: \u0026#34;http://localhost:3000\u0026#34;, reuseExistingServer: !process.env.CI, timeout: 60_000, }, }); ビューポートはモバイル専用の利用者向けサイトの設定例です。PC・モバイル両対応のサイトでは projects にPCとモバイルの2つを並べる構成にしています（§4のサイト別差分テーブル参照）。\nCI で workers: 1 にしている理由 §2の選定で「並列実行が無料」を決定打に挙げておきながら、上記configではCIに限り workers: 1 を指定しています。理由はMSWの状態競合の回避です。server.use() や server.resetHandlers() はプロセス内で共有されるグローバルstateを操作します。同一Next.js dev serverに対してテストを並列に走らせると、ハンドラーの上書きが相互に干渉します。今回はまず「19本のクリティカルパスをグリーンに通す」ことを優先し、安定性を取りました。\n並列度を上げたい場合の選択肢は2つあります。\ndev serverを workers 数だけ起動して水平分割: playwright.config.ts の webServer を複数起動するか、または port を動的に割り当てる方法。各テストを独立したMSWプロセスへ振り分ける形になる CI matrix で spec単位の分割: GitHub Actions / CircleCIのmatrix機能でspecファイルをチャンク化し、ジョブを並列実行する 今回は19本・40秒のスケールでは投資効果が薄かったため workers: 1 を選びました。本数が増えてきたタイミングで上記いずれかに切り替えます。\npackage.json 側のスクリプトはこうなります：\n1 2 3 4 5 6 7 8 { \u0026#34;scripts\u0026#34;: { \u0026#34;test\u0026#34;: \u0026#34;vitest run\u0026#34;, \u0026#34;test:watch\u0026#34;: \u0026#34;vitest\u0026#34;, \u0026#34;e2e\u0026#34;: \u0026#34;playwright test\u0026#34;, \u0026#34;e2e:ui\u0026#34;: \u0026#34;playwright test --ui\u0026#34; } } CIでは pnpm test と pnpm e2e を順に実行するだけです。\n5.5 networkidle は使わない Playwrightの waitForLoadState('networkidle') は、ネットワークリクエストが一定時間途絶えるまで待機する機能です。一見便利ですが、不安定なテストの原因 です。\n1 2 3 4 5 6 7 8 // ❌ 不安定: ポーリングや WebSocket があると永遠に待つ await page.waitForLoadState(\u0026#34;networkidle\u0026#34;); // ✅ SSR ページ: データ依存の要素が表示されるまで待つ await expect(page.getByText(\u0026#34;サンプル ユーザー\u0026#34;)).toBeVisible(); // ✅ CSR ページ: フォーム要素の表示で待つ await expect(page.getByRole(\u0026#34;button\u0026#34;, { name: \u0026#34;ログインする\u0026#34; })).toBeVisible(); 5.6 既知の制約 テスト基盤の設計段階で明らかになった制約と、その対策を整理しておきます：\n制約 影響 対策 SSR prefetch に page.route() が効かない E2E でエラー系テストが偽陽性になる Vitest + MSW で SSR エラーテストを補完 MSW モックのためサイト間データ連携の検証不可 サイトをまたいだデータ整合性は E2E では検証できない シナリオテスト（手動）およびユースケーステスト（バックエンド）で担保 waitForLoadState('networkidle') が不安定 MSW 環境でテストがフレーキーになる waitForSelector や waitForResponse で特定の要素・API を待つ 特に サイト間データ連携 はE2E（MSWモック）の根本的な限界です。利用者向けサイトでの操作が提供者向けサイトに反映されるかといった検証は、バックエンドのユースケーステストや手動のシナリオテストで担保する設計です。\n6. Sentry を本番運用に乗せる設計 Sentryは アプリ内のあらゆるデータを SaaS に送る 仕組みです。送信対象は、エラーのスタックトレース・HTTPリクエストの内容・ユーザー入力（breadcrumb）・Session ReplayのDOMなど多岐にわたります。デフォルト設定のまま本番に出すと、認証トークン・個人情報・業務上の機密データがSentryのサーバー側に保存され、漏洩時のリスクが大きくなります。\n特にチャット・通話・問い合わせフォームなどユーザー間のコミュニケーションを扱うサービスでは、本文・電話番号・決済情報がbreadcrumbやフォーム値経由でSentryに届きやすい構造です。\nこのセクションでは、Turbopack環境で初期化に詰まったポイントを共有します。さらに、本番運用に乗せるための 3層防御のマスキング、ソースマップを Sentry にアップロードしない選択肢、そして リリース前チェックリスト も解説します。\n6.1 Turbopack で instrumentation-client.ts を使う これが最もつまずいたポイントです。\nSentryの公式ドキュメントに従って sentry.client.config.ts を作成しましたが、ブラウザの Console に Sentry のログが一切出ません でした。\n原因は Next.js 16 + Turbopack 環境では、sentry.client.config.ts が自動読み込みされないことでした。\nSentryの @sentry/nextjs はwebpackのエントリポイントに sentry.client.config.ts を自動注入する仕組みです。しかし、Turbopackはこのwebpackプラグインを使いません。\n解決策: ファイルを instrumentation-client.ts にリネームする。\n❌ sentry.client.config.ts → Turbopack では読み込まれない ✅ instrumentation-client.ts → Turbopack の client instrumentation として認識される instrumentation-client.ts はNext.jsの公式APIであり、Turbopackでもクライアント側の初期化コードとして確実に実行されます。\nリネーム後にdev serverを再起動したところ、ConsoleにSentryのトレースログが大量に出力され、正常動作を確認できました。\n1 2 3 4 5 6 7 8 9 10 // instrumentation-client.ts（旧 sentry.client.config.ts） import * as Sentry from \u0026#34;@sentry/nextjs\u0026#34;; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, enabled: process.env.NODE_ENV === \u0026#34;production\u0026#34;, tracesSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, integrations: [Sentry.replayIntegration()], }); Sentry SDKを確認すると、webpackビルドでは2ファイルを順番に検索する実装でした。sentry.client.config.ts → instrumentation-client.ts の順です。後者が存在すれば前者の非推奨警告を出す挙動です。Turbopack移行を見据えると、今後は instrumentation-client.ts を使うのが正解です。\nサーバー側は instrumentation.ts でSSR/RSC由来のエラーを捕捉します。onRequestError フックを定義しておくと、SSRレンダリング中の例外もSentryに届きます：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // instrumentation.ts export async function register() { if (process.env.NEXT_RUNTIME === \u0026#34;nodejs\u0026#34;) { await import(\u0026#34;./sentry.server.config\u0026#34;); } if (process.env.NEXT_RUNTIME === \u0026#34;edge\u0026#34;) { await import(\u0026#34;./sentry.edge.config\u0026#34;); } } export async function onRequestError( error: unknown, request: { path: string; method: string; url: string; headers: Record\u0026lt;string, string | string[] | undefined\u0026gt;; }, context: { routerKind: string; routePath: string; routeType: string }, ) { const { captureRequestError } = await import(\u0026#34;@sentry/nextjs\u0026#34;); captureRequestError(error, request, context); } instrumentation-client.ts（ブラウザ側）と instrumentation.ts（サーバー側）の2ファイルが揃って、はじめてApp Routerのエラー全域がカバーされます。\n6.2 マスキング設計: 3層防御で機密データを Sentry に送らない Sentryの beforeSend フックで簡易マスクをかけている記事は多く見かけます。しかし、公式の \u0026ldquo;Scrubbing Sensitive Data\u0026rdquo; を基準に再設計したところ、beforeSend だけではギャップが残る と判明しました。\n漏れがちな箇所：\nevent.user（Sentry.setUser の値） スタックトレースに含まれるローカル変数値（frame.vars） event.extra / event.contexts / event.tags console カテゴリのbreadcrumb Session ReplayのDOMテキスト・入力値 これらをすべてカバーするため、SDK側・Sentryサーバ側・組織設定の3層で防御する設計にしました。\n%%{init: {\u0026#34;flowchart\u0026#34;: {\u0026#34;htmlLabels\u0026#34;: true, \u0026#34;nodeSpacing\u0026#34;: 60, \u0026#34;rankSpacing\u0026#34;: 80, \u0026#34;padding\u0026#34;: 20}, \u0026#34;themeVariables\u0026#34;: {\u0026#34;fontSize\u0026#34;: \u0026#34;15px\u0026#34;}}}%% flowchart LR A[\u0026#34;アプリ\u0026lt;br/\u0026gt;（エラー発生）\u0026#34;] L1[\u0026#34;第1層: SDK フック\u0026lt;br/\u0026gt;beforeSend / beforeBreadcrumb\u0026lt;br/\u0026gt;送信前にマスク\u0026#34;] B[\u0026#34;Sentry サーバ\u0026lt;br/\u0026gt;（受信）\u0026#34;] L2[\u0026#34;第2層: Server-side\u0026lt;br/\u0026gt;Data Scrubber\u0026lt;br/\u0026gt;保管前に追加キーをマスク\u0026#34;] C[\u0026#34;永続化 / UI 表示\u0026#34;] L3[\u0026#34;第3層: Generative AI\u0026lt;br/\u0026gt;機能停止\u0026lt;br/\u0026gt;組織設定 OFF + 学習オプトアウト\u0026#34;] A --\u0026gt; L1 --\u0026gt; B --\u0026gt; L2 --\u0026gt; C L3 -. 組織設定 .-\u0026gt; B classDef stage fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#0d47a1,padding:12px classDef layer fill:#fff3e0,stroke:#e65100,stroke-width:2.5px,color:#bf360c,padding:14px classDef result fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,color:#4a148c,padding:12px class A,B stage class L1,L2,L3 layer class C result 層 防御内容 設定箇所 役割 第1層 SDK の beforeSend / beforeSendTransaction / beforeBreadcrumb で送信前にマスク アプリコード 送信そのものを止める最強の防御 第2層 Sentry 組織の Data Scrubber を有効化、追加キーを登録 Sentry UI 第1層の漏れを最終ガード。SDK 改修なしで即時更新可 第3層 Generative AI 機能を組織全体で停止、学習利用はオプトアウト維持 Sentry UI データの AI 学習利用を明示的に防ぐ 第1層: SDK フックの実装方針 Sentry.init のオプションと beforeSend / beforeBreadcrumb で以下を徹底します：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 import * as Sentry from \u0026#34;@sentry/nextjs\u0026#34;; // アプリ側で `Sentry.setContext(\u0026#34;custom\u0026#34;, {...})` のように追加した // カスタムコンテキストだけホワイトリスト適用する。 // SDK 自動付与の `runtime` / `os` / `browser` / `trace` / `react` 等は触らない const ALLOWED_CUSTOM_CONTEXTS = [\u0026#34;app\u0026#34;]; // app_name / app_version / build_id 等 Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, enabled: process.env.NODE_ENV === \u0026#34;production\u0026#34;, // v10 でも既定 false だが、SDK アップデートでの挙動変化に備えて明示 sendDefaultPii: false, beforeSend(event) { // ① ユーザーコンテキストを完全削除（ID も送らない） // ⚠️ trade-off: ID を完全に消すと Sentry の「影響ユーザー数」が // 常に 1 になり、issue triage の重要シグナルが失われる。 // PII を出さずに unique count だけ維持したい場合は // `event.user = { id: sha256(originalId) }` のように // hash 化 ID を保持する選択肢もある delete event.user; // ② スタックトレースのローカル変数値を削除（サーバー側 SDK 向けの保険） // `frame.vars` は Node SDK の `localVariablesIntegration` // （V8 inspector 利用、サーバー側のみ）で初めて埋まる。 // ブラウザ SDK では基本 no-op だが、Next.js のサーバー側 SDK で同 // integration を有効化したときの漏洩防止として残しておく event.exception?.values?.forEach((exception) =\u0026gt; { exception.stacktrace?.frames?.forEach((frame) =\u0026gt; { delete frame.vars; }); }); // ③ カスタム追加した extra / contexts をホワイトリストで絞る // ⚠️ event.contexts には SDK 自動付与の `runtime` / `os` / `browser` / // `trace` / `react` が含まれる。distributed tracing と環境情報の // 要なので、これらを丸ごと上書きしてはいけない if (event.contexts?.custom) { event.contexts.custom = pickAllowed( event.contexts.custom as Record\u0026lt;string, unknown\u0026gt;, ALLOWED_CUSTOM_CONTEXTS, ); } event.extra = {}; // 初期は空。必要が生じたらレビューを経て許可キーを追加 // ④ HTTP リクエストの機密フィールドを削除 if (event.request) { delete event.request.cookies; if (event.request.headers) { delete event.request.headers[\u0026#34;authorization\u0026#34;]; delete event.request.headers[\u0026#34;cookie\u0026#34;]; } } return event; }, beforeBreadcrumb(breadcrumb) { // ⑤ console 出力をマスク（誤って機密情報を console.log した場合の保険） if (breadcrumb.category === \u0026#34;console\u0026#34;) { return { ...breadcrumb, message: \u0026#34;[masked console output]\u0026#34;, data: undefined }; } // ⑥ ui.input カテゴリ（フォーム入力等）をマスク if (breadcrumb.category?.startsWith(\u0026#34;ui.input\u0026#34;)) { return { ...breadcrumb, message: \u0026#34;[masked user input]\u0026#34; }; } return breadcrumb; }, }); 設計のポイントは カスタム拡張に限ったホワイトリスト方式 です。SDKが自動付与する runtime / os / browser / trace 等は調査・tracingに不可欠なので保持します。そのうえで、アプリ側で Sentry.setContext などで追加したカスタム値だけ許可キーで絞ります。「機密キーをブラックリストで弾く」とリスト漏れで漏洩しますが、「許可キーだけ通す」設計なら新しいキーが追加されたときに自動的にブロックされます。許可キーの追加はADRで履歴を残し、レビューを経てから反映する運用にしています。\npickAllowed ヘルパーはコピペで動くようにジェネリックで定義しておきます。\n1 2 3 4 5 6 7 8 9 function pickAllowed\u0026lt;T extends Record\u0026lt;string, unknown\u0026gt;\u0026gt;( obj: T | undefined, allowed: readonly string[], ): Partial\u0026lt;T\u0026gt; { if (!obj) return {}; return Object.fromEntries( Object.entries(obj).filter(([key]) =\u0026gt; allowed.includes(key)), ) as Partial\u0026lt;T\u0026gt;; } 参考: Sentry Dev Docs — Browser Tracing\n第1層補足: Session Replay は必ずマスクを有効化する Session Replayを使うなら、DOM上のテキストや入力値が録画されないように以下を必須にします：\n1 2 3 4 5 6 7 8 9 10 Sentry.init({ // ... integrations: [ Sentry.replayIntegration({ maskAllText: true, // すべてのテキストをマスク maskAllInputs: true, // すべての input 値をマスク blockAllMedia: true, // 画像・動画をブロック }), ], }); 「特定の要素だけ表示する」運用にしたい場合も、デフォルトでマスクし、必要な要素だけ解除する 方向にすると安全です。逆方向（デフォルト表示・機密要素だけブロック）はクラス付け漏れで簡単に破綻します。\n第2層: Server-side Data Scrubber 第1層がSDKのバグや実装漏れで効かなかったときの最終ガードとして、Sentry組織側でもServer-side Scrubberを有効化します。\nSentry UIの Organization Settings → Security \u0026amp; Privacy で以下を設定：\nData Scrubber: ON Use Default Scrubbers: ON: 既定キー（password / token / api_key / access_token / auth 等）をマスク Additional Sensitive Fields にサービス固有の機密フィールド名を追加。例： 連絡先系: telephone / phone / tel / email / birthday ID系: user_id / account_id コンテンツ系: message / comment / memo 決済系: card_number / payment_method / order_amount Prevent Storing of IP Addresses: ON 第1層と第2層を両方使う最大の利点は、SDK の再リリース不要で即座にマスク追加できる 点です。新しい機密フィールドが見つかったとき、Sentry UIで1行追加すれば全サイトに即時反映されます。\n第3層: Generative AI 機能の停止 SentryにはSeer（Auto-Fix, Issue Summary等）というGenerative AI機能があります。エラー内容をAIが要約・解析する便利な機能ですが、機密データを含むエラーが AI に渡される可能性 があります。\n機密度の高いサービスでは、組織全体で停止するのが安全です：\nOrganization Settings → \u0026ldquo;Show Generative AI Features\u0026rdquo; を OFF Seerの各機能（Auto-Fix, Issue Summary等）も個別にOFF データのAI学習利用はデフォルトで opt-out（SentryのAI Privacy Principles参照）だが、設定画面でオフ状態を確認しスクリーンショットを残す Sentryのプライバシーポリシーには次の記載があります。\nBy default, your data will not be used to train any generative AI models without your permission\nとはいえ、念のため設定画面で オフ状態を確認・スクリーンショットを保管 しておくと、社内のセキュリティ監査や問い合わせがあったときに即答できます。\n6.3 ソースマップを Sentry にアップロードしないという選択肢 Sentryのドキュメントに従えば、CIでソースマップをアップロードしてブラウザに公開しない（hidden-source-map）構成が標準です。Sentry上でスタックトレースが元のソースコードに解決され、調査が一気に速くなります。\nしかし、ソースマップそのものを Sentry に置かない という選択肢もあります。\nソースマップに含まれる関数名・変数名・コード断片そのものが「Sentryに置きたくない情報」になりえる場合です。万が一Sentry側で漏洩が起きたとき、ソースマップがあるとアプリの内部実装が完全に復元できてしまいます。\nローカルで手動解決する運用 そこで、機密度の高いサービスでは以下の運用に切り替える選択肢があります：\nビルド時に .next/ 配下へソースマップを生成する（ローカル保持） withSentryConfig の sourcemaps.disable を true に固定し、Sentryへのアップロードを止める エラー解析時はローカルでソースマップを使って手動解決 具体的な設定は次の通りです。\n1 2 3 4 5 6 7 8 // next.config.js const { withSentryConfig } = require(\u0026#34;@sentry/nextjs\u0026#34;); module.exports = withSentryConfig(nextConfig, { sourcemaps: { disable: true, // Sentry へのソースマップアップロードを止める }, }); 参考: Sentry Build Options — sourcemaps.disable / Source Maps (Next.js)\nflowchart TD A[\u0026#34;Sentry でエラー検知\u0026#34;] --\u0026gt;|Slack 通知| B[\u0026#34;担当が気づく\u0026#34;] B --\u0026gt; C{\u0026#34;.next/ に .map があるか？\u0026#34;} C --\u0026gt;|ある| E[\u0026#34;③ source-map ライブラリで\u0026lt;br/\u0026gt;元ファイル・行番号を解決\u0026#34;] C --\u0026gt;|なければ| D[\u0026#34;② git checkout \u0026amp;lt;該当 release のコミット\u0026amp;gt;\u0026lt;br/\u0026gt;\u0026amp;amp;\u0026amp;amp; pnpm build\u0026#34;] D --\u0026gt; E E --\u0026gt; F[\u0026#34;④ 該当コードのスニペットを表示\u0026#34;] このフローはスクリプト化（あるいはSlackスラッシュコマンド化）して、誰でもワンコマンドで回せる状態にしておくのが必須です。手動解決のままだと属人化します。\nトレードオフと対策 課題 対策 エラー調査時に手動解決の手間が入る スクリプト化（/resolve-sourcemap 的なコマンド）でワンコマンド化 担当者の手元ビルド環境が必要 git checkout main \u0026amp;\u0026amp; pnpm build を標準フローに含め、環境変数を固定 過去リリースの再現性 エラーの release タグからコミットハッシュを引いてビルド 属人化 スクリプトとデモ動画をチームに共有して、複数人が回せる状態を担保 調査効率は当然落ちます。しかし、「便利さ」と「漏洩時の影響範囲を最小に抑えること」のトレードオフ で後者を取る判断です。Sentryの標準構成が常に正解とは限りません。\n6.4 リリース前チェックリスト Sentryを本番運用に乗せる前は、以下を毎回確認します：\nbeforeSend の単体テストがPASS（マスキング処理が想定通り動くか） sendDefaultPii: false が Sentry.init に明示されている ステージングで実エラーを発生させ、Sentry上のeventに PII / 認証情報が含まれないこと をサンプル確認（最低5件） Sentry UIのData ScrubberがON、追加キーが登録済み Generative AI機能がOFF、学習利用がオプトアウト状態 ソースマップがSentryにアップロードされていない（環境変数・ビルド設定で固定） エビデンス（スクリーンショット・サンプルイベント）を社内ドキュメントに格納 特にサンプル確認は重要です。beforeSend を書いただけでは、本当に消えているかはSentry上で見てみないと分かりません。「実装した」と「機能している」は別物として運用に組み込みます。\n7. コスト: テストを書く工数に見合うか 「テストを書く時間でバグ修正した方が早くない？」という疑問への回答です。\n定性面の比較 テストなし（導入前） テストあり（導入後） リグレッション検知 手動で全画面再確認（30分〜1時間/回） CI で自動検知（数分） バグ修正後の再確認 修正者が手動で再テスト pnpm e2e で即確認 本番エラー検知 ユーザー報告経由（数時間〜数日遅れ） Sentry でリアルタイム通知 導入工数（実測） 作業 工数 成果物 Vitest 導入 + スキーマテスト13本 1〜2時間（AI 生成 + レビュー） Zod スキーマ13本、PASS 13/13 E2E テスト6本（検索→詳細フロー） 1〜2時間（AI 生成 + デバッグ） 6本中6本グリーン、実行時間約40秒 Sentry 導入 + ErrorBoundary 統合 1〜2時間（wizard + Turbopack 対応） 3サイト分の instrumentation-client.ts 整備 合計 約半日（4〜6時間） テスト19本 + 監視基盤 数字で見る回収ライン 導入前は1回の手動再確認に30分〜1時間かかっていました。導入後はCIによる自動検知に置き換わり、再確認時間はゼロです。バグ修正→デグレ確認のループが1日あたり3〜5回発生していた状況を踏まえると、1〜2日で導入工数を回収できる試算 です。\n実装の大半はAI（Claude Code）で生成し、人間はレビューと動作確認が中心です。configの生成、テストコードの雛形作成、Zodスキーマからのテストケース導出はAIが得意な作業です。\n8. 属人化防止: 自分だけがテストを書ける状態にしない テスト基盤を導入しても、特定の人しかテストを書けない状態では意味がありません。\nテンプレートの標準化 Playwrightのconfig、MSWの連携パターン、Vitestのセットアップファイルはすべてテンプレート化し、新しいサイトへの横展開はテンプレートのコピーで完了するようにしました。\nAI による量産フロー インプット: 1. 対象コンポーネントのソースコード 2. MSW ハンドラー（モックデータ） 3. テスト項目（ユースケースから振り分けたもの） → AI が .test.tsx / .spec.ts を生成 → 人間がレビュー + 動作確認 CI でのゲート テストをCIに組み込むことで、テストが壊れたら PR を出した人が修正する 運用になります。テスト作成者だけが保守する状態にはなりません。\n9. AI 生成テストの落とし穴: 「成功するテストしか作らない」問題 AIでテストを量産する際に気づいた重要な課題があります。\n問題: コードから生成すると「バグを正解として固定する」 AIにソースコードを渡して「このコンポーネントのテストを書いて」と指示すると、現在の実装を正解とするテスト が生成されます。\nソースコード → AI → テスト生成 → 「この実装の振る舞いが正しい」前提で期待値を設定 → バグがあってもテストは通る これは「テスト成功＝品質の担保」という誤った安心感を生みます。\n実体験: 同じ AI でも、入力が違えば結果が変わる 今回の導入では、Zodスキーマテスト13本とE2Eページ遷移フロー6本をAIで生成しました。結果は対照的でした：\nZod スキーマテスト: スキーマ定義そのものが「仕様」のため、コードから生成して問題なし。13本中13本が意図通りのテストになった E2E ページ遷移フロー:「ユーザーがどう動線を通るか」という仕様情報を渡さなかったため、AIは実装の振る舞いをそのままテスト化。実装 PoC の域を出ない品質 にとどまった → 仕様情報を持たないテストは、実装が変わったときに「正しく壊れる」ことができません。これがPhase 2以降は仕様書ベースで生成すべき理由です。\n対策: テストのインプットを「コード」ではなく「仕様」にする ❌ コード → AI → テスト（実装が正解になる） ✅ 仕様書 → AI → テスト → コードに対して実行（仕様が正解になる） 仕様書ベースでテストを先に書けば、テストが落ちたときに「コード側のバグ」と判断できます。本来のテストの使い方です。\n使い分けの基準 すべてのテストを仕様ベースで書く必要はありません。テストの種類によって使い分けます。\nテストの種類 コードから生成してOK 仕様から生成すべき Zod スキーマバリデーション ✅ スキーマ定義＝仕様そのもの — スナップショットテスト ✅ リグレッション検知が目的 — E2E ページ遷移フロー — ✅ ユーザーの期待する動線 異常系・境界値テスト — ✅ 仕様上の制約を検証 段階的な進め方 Phase 1（基盤構築期）: コードから生成 → リグレッション防止用 - 「今の動作を壊さない」ためのテスト - 基盤の証明が目的なのでこれで OK Phase 2（テスト拡充期）: 仕様書から生成 → バグ発見用 - ユースケース一覧をインプットに - AI にコードは渡さず「この仕様を満たすテストを書いて」と指示 - テストが落ちたらコード側のバグ Phase 3（運用期）: 両方を組み合わせ - 仕様ベーステスト: 新機能・重要フロー - コードベーステスト: リファクタリング時のリグレッション防止 今回の導入では、基盤の証明が目的だったのでPhase 1（コードベース）で問題ありませんでした。次フェーズでは仕様書（ユースケース一覧）をインプットとする運用へ移行します。\nまとめ テスト基盤をゼロから整備して学んだことをまとめます。\nPlaywright 選定の決め手は AI 連携・並列実行・マルチブラウザ — SSR + MSWのサポートは「msw/node をdev serverに同居させる設計」がコア。ランナー（Playwright/Cypress）には依存しない。3サイト横断でスケールするAI連携と並列実行が決定打 全ページ E2E にしない — テストピラミッドの原則に従い、クリティカルパス（ページ遷移フロー）はE2E、バリデーション等はVitestで分担する MSW ハンドラーを E2E と Vitest で共有する — [...customHandlers, ...orvalHandlers] でカスタム優先・自動生成フォールバックの構成にする。モックの二重管理がなくなる MSW の2層構造は必須 — サーバー側（msw/node）とブラウザ側（msw/browser）の両方でMSWを起動しないと、SSR prefetchにモックが効かない Turbopack 時代の正解は instrumentation-client.ts — webpack専用の sentry.client.config.ts はTurbopackで読み込まれない Sentry のマスキングは beforeSend だけでは不十分 — SDKフック・組織側Server-side Scrubber・Generative AI機能の停止の3層で設計する。extra / contexts / tags はホワイトリスト方式が安全 ソースマップを Sentry にアップロードしない選択肢もある — 機密度の高いサービスでは、ローカル保持＋手動解決スクリプトで運用する方が漏洩時の影響範囲を抑えられる AI でテストを生成するなら「コード」ではなく「仕様」を起点に — コードから生成するとバグを正解として固定する。仕様書ベースで生成すれば、テスト失敗＝コードのバグとして機能する AI で量産、人間はレビュー — config生成もテストコード作成もAIが得意。半日で3ツールの導入が完了した テスト基盤は「1本目のテストを書くまで」が最も重いです。仕組みさえできれば、あとはテンプレートとAIで量産できます。\n次回予告: AI（Claude Code / Devin / NotebookLM）を活用したテスト仕様書作成の実践。「コードから生成」を「仕様から生成」に切り替える具体的なワークフローを紹介予定です。\n","permalink":"https://andfactory.co.jp/techblog/posts/nextjs-test-infrastructure-from-zero","summary":"\u003cp\u003eQAフェーズへの移行を機に、後回しにしていたテスト基盤を一気に整備した記録です。\u003c/p\u003e\n\u003cp\u003e同じドメインで連携する3つのNext.jsサイト（利用者向け/提供者向け/社内管理画面）を並行開発し、リリース前のQAに突入したものの、モンキーテストで予想を超えるバグが多発しました。\u003c/p\u003e\n\u003cp\u003e修正のたびに3サイト全画面を手動で再確認することになり、1サイクルあたり30分〜1時間かかりました。本来テストシナリオ作成に充てるはずだった1週間がバグ修正で埋まりました。\u003c/p\u003e\n\u003cp\u003eそこでE2Eテスト（Playwright）・コンポーネントテスト（Vitest）・本番エラー監視（Sentry）を \u003cstrong\u003e半日で一気に導入\u003c/strong\u003e しました。\u003c/p\u003e\n\u003cp\u003eこの記事では、\u003cstrong\u003eNext.js App Router + TanStack Query + MSW\u003c/strong\u003e という構成で、ツール選定から実装・CI統合までの過程を解説します。あわせて、SSR / Turbopack固有のハマりポイントと、AIでテストを量産する際の落とし穴も共有します。\u003c/p\u003e\n\u003ch3 id=\"この記事で得られること\"\u003eこの記事で得られること\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eNext.js App Routerで \u003cstrong\u003ePlaywright + MSW\u003c/strong\u003e を動かすためのSSR対応パターン\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eMSW ハンドラーを E2E と Vitest で共有する設計\u003c/strong\u003e（モックの二重管理を防ぐ）\u003c/li\u003e\n\u003cli\u003eE2E / Vitest / 手動テストの \u003cstrong\u003e仕分け基準\u003c/strong\u003e（テストピラミッドの設計）\u003c/li\u003e\n\u003cli\u003eSentryを \u003cstrong\u003eNext.js 16 + Turbopack\u003c/strong\u003e で動かす際のハマりポイントと解決策\u003c/li\u003e\n\u003cli\u003eSentryの \u003cstrong\u003e3層防御マスキング設計\u003c/strong\u003e（SDKフック / Server-side Scrubber / Generative AI機能停止）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eソースマップを Sentry にアップロードしない\u003c/strong\u003e という運用選択肢とそのトレードオフ\u003c/li\u003e\n\u003cli\u003eAIでテストを量産する際の「成功するテストしか作らない」問題と対策\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"動作確認環境\"\u003e動作確認環境\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e種別\u003c/th\u003e\n          \u003cth\u003eバージョン\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eOS\u003c/td\u003e\n          \u003ctd\u003emacOS 15.x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eNode.js\u003c/td\u003e\n          \u003ctd\u003e24.x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eパッケージマネージャー\u003c/td\u003e\n          \u003ctd\u003epnpm 10.x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eNext.js\u003c/td\u003e\n          \u003ctd\u003e16.x（Turbopack）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eReact\u003c/td\u003e\n          \u003ctd\u003e19.x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e@playwright/test\u003c/td\u003e\n          \u003ctd\u003e1.58.x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003evitest\u003c/td\u003e\n          \u003ctd\u003e4.x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003emsw\u003c/td\u003e\n          \u003ctd\u003e2.x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e@sentry/nextjs\u003c/td\u003e\n          \u003ctd\u003e10.x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e@tanstack/react-query\u003c/td\u003e\n          \u003ctd\u003e5.x\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-背景-手動テストだけでは回らなくなった瞬間\"\u003e1. 背景: 手動テストだけでは回らなくなった瞬間\u003c/h2\u003e\n\u003cp\u003e3つのNext.jsサイト（利用者向けサイト/提供者向けサイト/社内管理画面）を約10名のチームで半年かけて並行開発しました。\u003c/p\u003e","title":"QA フェーズを起点に、Next.js のテスト基盤をゼロから設計した話 — Playwright × Vitest × Sentry"},{"content":"はじめに こんにちは。and factory フロントエンドエンジニアの坂内です。\n4月の個人テーマとして、Webアクセシビリティ（WCAG・WAI-ARIA）の基礎を調べ、自社プロダクトで計測まで試したので共有します。\n「アクセシビリティは大事」と聞くものの、何から手をつければよいか分からないと感じるエンジニアは多いはずです。本記事はフロントエンドの実装視点で、まず押さえておきたい範囲に絞ってまとめました。\n結論 先に持ち帰ってほしいポイントを3つに絞ります。\n実務の目標ラインはAAレベル準拠（JIS X 8341-3:2016もWCAG 2.0と同内容のため、AAが事実上のスタンダード） 大原則は「No ARIA is better than Bad ARIA」で、\u0026lt;button\u0026gt; を使えばロール・キーボード操作・フォーカスがすべて自動で揃う 自社プロダクト4ページの横断計測の平均は93点（自動検査の範囲）で、指摘はselectのラベル不足とコントラスト比不足が中心、修正コストも小さい 詳細を順番に説明します。\n調査の背景 私が担当しているプロダクトは占いコンテンツのWebサービスです。複数ページで同じ \u0026lt;select\u0026gt; やドロップダウンが繰り返し登場するため、共通コンポーネント越しにUIを組み立てる構成です。\nフロントエンドの実装では、\u0026lt;div\u0026gt; や \u0026lt;span\u0026gt; だけでUIを組む場面が増えました。Reactなどのライブラリでカスタムコンポーネントを作る場合、見た目はモーダルやドロップダウンに見えても、HTML上は意味を持たないただの箱になりがちです。\nこの状態では、スクリーンリーダーや支援技術を使うユーザーから見ると、機能の伝わらないコンポーネントが増えていきます。\n現状把握として、ChromeのLighthouseでトップページを計測したところ、Accessibilityスコアは86点でした。検出された指摘は \u0026lt;select\u0026gt; のラベル不足やコントラスト比不足など、共通コンポーネントとデザイントークン由来の項目が中心です。共通部品の不備は複数ページに波及します。改修へ進む前段階として、まず基礎を押さえておきたいと判断しました。\n以前からアクセシビリティは気になっていたテーマでもあり、チームでSEOやUI品質について話し合う機会が増えたことも後押しになりました。そこで4月の個人テーマとして、WCAG・WAI-ARIAの基礎調査と自社プロダクトの横断計測を据えました。次の章からWCAG・WAI-ARIAの順に整理し、最後に自社プロダクトの計測結果へつなげます。\nWCAGとは WCAG（Web Content Accessibility Guidelines）は、W3Cが策定したWebアクセシビリティの国際ガイドラインです。障害のある人や高齢者を含むあらゆるユーザーが、Webコンテンツを利用できるようにするための指針です。\n4つの原則（POUR） すべての達成基準は、次の4つの原則に分類できます。\n原則 内容 例 Perceivable（知覚可能） 情報がユーザーに認識できる 画像のalt、字幕 Operable（操作可能） UIが操作できる キーボード操作対応 Understandable（理解可能） 内容や操作方法が理解できる エラー表示の分かりやすさ Robust（堅牢） 多様な環境で動く 正しいHTML構造 3段階の適合レベル レベル 位置づけ 具体例 A 最低限。これがないと使えない人が出る キーボード操作可能、画像にalt属性 AA 一般的に目指すべき標準 コントラスト比4.5:1以上、200%拡大対応 AAA 最高レベル。完全準拠は現実的に困難 コントラスト比7:1以上、手話付き動画 日本ではJIS X 8341-3:2016がWCAG 2.0と同内容のため、公的機関でも参照されています。実務で目標とする標準ラインは AA準拠 です。\nバージョンの違い WCAG 2.0（2008年）：4原則・12ガイドラインで構成される基礎 WCAG 2.1（2018年）：モバイル・弱視・認知障害への対応を追加 WCAG 2.2（2023年）：さらに9つの達成基準を追加（現時点の最新勧告） いずれも後方互換性があるため、新しいバージョンに準拠すれば前のバージョンも満たす扱いです。\nAAレベルで特に押さえる5つのポイント 達成基準は多数ありますが、フロントエンド実装で特に頻出する5つを取り上げます（WCAG AAの全達成基準を網羅するものではありません）。\nコントラスト比 4.5:1 以上（大きいテキストは3:1以上） キーボードのみで全機能が操作できる（フォーカス可視、トラップなし） フォームにラベルやエラー表示がある（labelやaria-labelの付与） 見出しが意味的に正しく構造化されている（h1〜h6を装飾目的で使わない） 画面幅320pxでも情報が失われない（リフロー対応、WCAG 2.1 1.4.10から追加） ここまでがWCAGの全体像です。次は、HTMLだけでは表現しきれない部分を補うWAI-ARIAを見ていきます。\nWAI-ARIAとは WAI-ARIA（Web Accessibility Initiative – Accessible Rich Internet Applications）はW3Cが策定した仕様です。HTMLだけでは表現しきれないUIの「役割・状態・プロパティ」を支援技術へ伝える機能を担います。\nたとえば、見た目はモーダルでもHTML上はただの \u0026lt;div\u0026gt; というケースに対し、「これはダイアログだ」「いま開いている」といった情報を補えます。\n3つの構成要素 graph LR A[WAI-ARIA] --\u0026gt; B[\u0026#34;Role 役割\u0026#34;] A --\u0026gt; C[\u0026#34;Property プロパティ\u0026#34;] A --\u0026gt; D[\u0026#34;State 状態\u0026#34;] B --\u0026gt; B1[\u0026#34;role=button\u0026lt;br\u0026gt;role=dialog\u0026#34;] C --\u0026gt; C1[\u0026#34;aria-label\u0026lt;br\u0026gt;aria-labelledby\u0026#34;] D --\u0026gt; D1[\u0026#34;aria-expanded\u0026lt;br\u0026gt;aria-hidden\u0026#34;] 以下、図と同じ内容をテキストで示します。\nRole：要素の役割（button、dialog、navigationなど） Property：静的な性質（aria-label、aria-labelledbyなど） State：動的に変化する状態（aria-expanded、aria-selectedなど） 大原則：No ARIA is better than Bad ARIA WAI-ARIAを学ぶ前に押さえておきたい大原則があります。それが「No ARIA is better than Bad ARIA」（雑なARIAを付けるくらいなら、何も付けない方がよい）です。\nW3Cのドキュメント（Using ARIA）でも、最初のルールは「ネイティブHTML要素で実現できるならそれを使う」と明記されています。\n1 2 3 4 5 6 7 8 9 10 11 \u0026lt;!-- BAD: divでボタンを再発明する - Enter/Space で発火しない（tabindex=\u0026#34;0\u0026#34; だけでは onclick が動かない） - disabled 属性が効かない - フォーカスリングのデフォルトが付かない --\u0026gt; \u0026lt;div role=\u0026#34;button\u0026#34; tabindex=\u0026#34;0\u0026#34; onclick=\u0026#34;handleClick()\u0026#34;\u0026gt;送信\u0026lt;/div\u0026gt; \u0026lt;!-- GOOD: ネイティブHTMLを使う --\u0026gt; \u0026lt;button type=\u0026#34;button\u0026#34;\u0026gt;送信\u0026lt;/button\u0026gt; \u0026lt;script\u0026gt; document.querySelector(\u0026#39;button\u0026#39;).addEventListener(\u0026#39;click\u0026#39;, handleClick); \u0026lt;/script\u0026gt; \u0026lt;button\u0026gt; を使うだけで、ロール・キーボード操作・フォーカスが自動で揃います。WAI-ARIAは、HTMLでどうしても表現できないときの補助として位置づけます。\nよく使う属性3選 実装で頻出する3つの属性を、コード例とともに整理します。\nrole — これは何の要素か 要素の役割を支援技術へ伝える属性です。HTMLの意味論を補う用途で使います。\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;!-- ナビゲーションが複数ある場合は名前で区別する --\u0026gt; \u0026lt;nav aria-label=\u0026#34;メインメニュー\u0026#34;\u0026gt;...\u0026lt;/nav\u0026gt; \u0026lt;nav aria-label=\u0026#34;フッター\u0026#34;\u0026gt;...\u0026lt;/nav\u0026gt; \u0026lt;!-- ネイティブ \u0026lt;dialog\u0026gt; 要素を使う（Baseline 2023: 全主要ブラウザ対応済み） showModal() を呼ぶだけで focus trap / Escape / aria-modal 相当の挙動が得られる --\u0026gt; \u0026lt;dialog id=\u0026#34;confirm-dialog\u0026#34; aria-labelledby=\u0026#34;dialog-title\u0026#34;\u0026gt; \u0026lt;h2 id=\u0026#34;dialog-title\u0026#34;\u0026gt;確認\u0026lt;/h2\u0026gt; \u0026lt;p\u0026gt;削除してもよろしいですか？\u0026lt;/p\u0026gt; \u0026lt;button\u0026gt;キャンセル\u0026lt;/button\u0026gt; \u0026lt;button\u0026gt;削除する\u0026lt;/button\u0026gt; \u0026lt;/dialog\u0026gt; \u0026lt;!-- dialog.showModal() で開く --\u0026gt; \u0026lt;dialog\u0026gt; が使えない場合のみ role=\u0026quot;dialog\u0026quot; を検討してください。ただしその場合、aria-modal=\u0026quot;true\u0026quot;・focus trap・Escape処理・返却フォーカスの実装がすべて別途必要です。\n\u0026lt;button\u0026gt; や \u0026lt;nav\u0026gt; など適切なHTMLタグを使う場合、role の付与は不要です。\naria-label — 名前を付ける 視覚的なラベルがない要素にアクセシブルな名前を与える属性です。アイコンのみのボタンや、コンテキスト依存の入力欄で使います。\n1 2 3 4 5 6 7 8 \u0026lt;!-- アイコンボタン：見た目は ✕、読み上げは「閉じる」 --\u0026gt; \u0026lt;button aria-label=\u0026#34;閉じる\u0026#34;\u0026gt; \u0026lt;svg aria-hidden=\u0026#34;true\u0026#34;\u0026gt;...\u0026lt;/svg\u0026gt; \u0026lt;/button\u0026gt; \u0026lt;!-- 検索ボックス：可視ラベルを置けない場合のみ aria-label を使う 可視 \u0026lt;label\u0026gt; を置ける場合は \u0026lt;label\u0026gt; を優先する（クリック領域も広がる） --\u0026gt; \u0026lt;input type=\u0026#34;search\u0026#34; aria-label=\u0026#34;サイト内検索\u0026#34;\u0026gt; aria-hidden — 支援技術から隠す 装飾目的の要素をアクセシビリティツリーから除外する属性です。スクリーンリーダーで読み上げさせたくない要素に使います。\n1 2 3 4 5 6 7 8 9 10 11 \u0026lt;!-- 装飾アイコン：隣にテキストがあるので読み上げ不要 --\u0026gt; \u0026lt;button\u0026gt; \u0026lt;svg aria-hidden=\u0026#34;true\u0026#34;\u0026gt;...\u0026lt;/svg\u0026gt; 保存する \u0026lt;/button\u0026gt; \u0026lt;!-- 意味を持つ SVG（ロゴ・ステータスアイコンなど）：role=\u0026#34;img\u0026#34; と \u0026lt;title\u0026gt; で名前を付ける --\u0026gt; \u0026lt;svg role=\u0026#34;img\u0026#34; aria-labelledby=\u0026#34;logo-title\u0026#34;\u0026gt; \u0026lt;title id=\u0026#34;logo-title\u0026#34;\u0026gt;and factory\u0026lt;/title\u0026gt; ... \u0026lt;/svg\u0026gt; ここで重要な注意点があります。aria-hidden=\u0026quot;true\u0026quot; を フォーカス可能な要素（button、a、inputなど）に付けてはいけません。キーボード操作で「見えない要素」にフォーカスが当たり、ユーザーを混乱させます。要素を視覚的に隠しつつ支援技術からも除外したい場合は、tabindex=\u0026quot;-1\u0026quot; でフォーカス対象から外したうえで aria-hidden=\u0026quot;true\u0026quot; を組み合わせます。\n基礎をひと通り押さえたところで、最後に自社プロダクトを計測してみます。\n実プロダクトのLighthouse計測結果 ここまでの基礎知識を踏まえ、弊社が運営する「星ひとみの占いサイト」をChromeのLighthouseで計測しました。代表的な4ページを横断的に確認しました。\n計測環境 OS：macOS 15 ブラウザ：Google Chrome（2026年4月計測時点の最新版） ツール：Lighthouse（Chrome DevTools内蔵版） form factor：Mobile（デフォルト） スロットリング：Simulated Slow 4G, 4× CPU Slowdown（デフォルト） 計測回数：各ページ1回 状態：未ログイン（シークレットウィンドウ） 観点：Accessibilityカテゴリのみ スコアサマリー ページ スコア 主な指摘 トップ 86 / 100 selectにラベルなし、コントラスト比不足 占い紹介 92 / 100 selectにラベルなし 占い詳細 95 / 100 コントラスト比不足 監修者 100 / 100 自動検出の指摘なし スコアの平均は93点でした。今回の最大の発見は、ページごとに点数と指摘内容が大きく異なるという点です。\nただしLighthouse Accessibilityは自動検出可能なルールの範囲のみを対象としており、スコア100でもWCAG AA準拠の証明にはなりません。Lighthouse自身も結果画面で手動監査の必要性を明示しています。本記事の数字は「自動検査で検出された範囲」として読んでください。\n検出された問題と修正方針 ① selectにラベルがない \u0026lt;select\u0026gt; に \u0026lt;label\u0026gt; / aria-label / aria-labelledby のいずれもついていない、という指摘です。スクリーンリーダーで読み上げると「何のための選択肢か」が伝わりません。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026lt;!-- Before --\u0026gt; \u0026lt;select\u0026gt; \u0026lt;option value=\u0026#34;1990\u0026#34;\u0026gt;1990\u0026lt;/option\u0026gt; \u0026lt;option value=\u0026#34;1991\u0026#34;\u0026gt;1991\u0026lt;/option\u0026gt; \u0026lt;/select\u0026gt; \u0026lt;!-- After: label を関連付ける --\u0026gt; \u0026lt;label for=\u0026#34;birth-year\u0026#34;\u0026gt;生まれ年\u0026lt;/label\u0026gt; \u0026lt;select id=\u0026#34;birth-year\u0026#34;\u0026gt; \u0026lt;option value=\u0026#34;1990\u0026#34;\u0026gt;1990\u0026lt;/option\u0026gt; \u0026lt;/select\u0026gt; \u0026lt;!-- After: aria-label で補う --\u0026gt; \u0026lt;select aria-label=\u0026#34;生まれ年\u0026#34;\u0026gt; \u0026lt;option value=\u0026#34;1990\u0026#34;\u0026gt;1990\u0026lt;/option\u0026gt; \u0026lt;/select\u0026gt; ② 背景色と文字色のコントラスト比不足 WCAG AAの基準（通常テキスト4.5:1、大きい文字3:1）を満たしていないという指摘です。弱視や色覚特性のあるユーザーにとって読みづらくなります。\n修正にはサイト全体のデザイントークン（CSS変数や設計システム上の色）の見直しが効きます。チェック用のツールには次の2つが使いやすいです。\nChrome DevToolsのカラーピッカー（コントラスト比をその場で確認できる） WebAIM Contrast Checker（任意の2色を入力して判定できる） 4ページ比較してわかったこと ページ単位の点数だけ見ると、サイト全体の状態を見誤ります。今回横断で計測したことで、次の3点が分かりました。\nselectのラベル問題はselectを使うページで共通して発生し、共通コンポーネントを直せば複数ページで一気に改善可能 コントラスト比の問題はトップと占い詳細で共通発生し、サイト全体のデザイントークン起因の可能性が高くデザイナーとの連携が前提 監修者ページは満点で、シンプルなコンテンツ中心のページは特別な対応をしなくても高スコアになりやすい つまり、サイト全体の平均点だけを見て「うちは80点台だ」と判断すると本質を見失います。代表的な数ページを横断で計測し、共通コンポーネントとデザイントークンに分けて改善計画を立てる方が実務的です。\nまとめ 4月のテーマとして、WCAGとWAI-ARIAの基礎を調べ、自社プロダクトで計測しました。学びは大きく次の3点です。\nAAレベルが実務の目標ライン。コントラスト・キーボード・ラベル・見出し構造・リフローの5点を押さえる WAI-ARIAより前にネイティブHTMLを使う。「No ARIA is better than Bad ARIA」を念頭に置く Lighthouseは敷居が低く、共通コンポーネントとデザイントークンに分けた改善計画の起点として活用できる 自動ツールで検出できるのは一部の問題に限られます。引き続きアクセシビリティ改善を進め、得られた気づきは別記事で共有します。\n参考リンク WCAG 2.1 日本語訳 WCAG 2.2（W3C 勧告） Using ARIA（W3C） ARIA Authoring Practices Guide（APG） MDN - WAI-ARIA の基本 axe DevTools Lighthouse 使い方 ","permalink":"https://andfactory.co.jp/techblog/posts/frontend-accessibility-wcag-wai-aria","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eこんにちは。and factory フロントエンドエンジニアの坂内です。\u003c/p\u003e\n\u003cp\u003e4月の個人テーマとして、Webアクセシビリティ（WCAG・WAI-ARIA）の基礎を調べ、自社プロダクトで計測まで試したので共有します。\u003c/p\u003e\n\u003cp\u003e「アクセシビリティは大事」と聞くものの、何から手をつければよいか分からないと感じるエンジニアは多いはずです。本記事はフロントエンドの実装視点で、まず押さえておきたい範囲に絞ってまとめました。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"結論\"\u003e結論\u003c/h2\u003e\n\u003cp\u003e先に持ち帰ってほしいポイントを3つに絞ります。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e実務の目標ラインはAAレベル準拠（JIS X 8341-3:2016もWCAG 2.0と同内容のため、AAが事実上のスタンダード）\u003c/li\u003e\n\u003cli\u003e大原則は「No ARIA is better than Bad ARIA」で、\u003ccode\u003e\u0026lt;button\u0026gt;\u003c/code\u003e を使えばロール・キーボード操作・フォーカスがすべて自動で揃う\u003c/li\u003e\n\u003cli\u003e自社プロダクト4ページの横断計測の平均は93点（自動検査の範囲）で、指摘はselectのラベル不足とコントラスト比不足が中心、修正コストも小さい\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e詳細を順番に説明します。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"調査の背景\"\u003e調査の背景\u003c/h2\u003e\n\u003cp\u003e私が担当しているプロダクトは占いコンテンツのWebサービスです。複数ページで同じ \u003ccode\u003e\u0026lt;select\u0026gt;\u003c/code\u003e やドロップダウンが繰り返し登場するため、共通コンポーネント越しにUIを組み立てる構成です。\u003c/p\u003e\n\u003cp\u003eフロントエンドの実装では、\u003ccode\u003e\u0026lt;div\u0026gt;\u003c/code\u003e や \u003ccode\u003e\u0026lt;span\u0026gt;\u003c/code\u003e だけでUIを組む場面が増えました。Reactなどのライブラリでカスタムコンポーネントを作る場合、見た目はモーダルやドロップダウンに見えても、HTML上は意味を持たないただの箱になりがちです。\u003c/p\u003e\n\u003cp\u003eこの状態では、スクリーンリーダーや支援技術を使うユーザーから見ると、機能の伝わらないコンポーネントが増えていきます。\u003c/p\u003e\n\u003cp\u003e現状把握として、ChromeのLighthouseでトップページを計測したところ、Accessibilityスコアは86点でした。検出された指摘は \u003ccode\u003e\u0026lt;select\u0026gt;\u003c/code\u003e のラベル不足やコントラスト比不足など、共通コンポーネントとデザイントークン由来の項目が中心です。共通部品の不備は複数ページに波及します。改修へ進む前段階として、まず基礎を押さえておきたいと判断しました。\u003c/p\u003e\n\u003cp\u003e以前からアクセシビリティは気になっていたテーマでもあり、チームでSEOやUI品質について話し合う機会が増えたことも後押しになりました。そこで4月の個人テーマとして、WCAG・WAI-ARIAの基礎調査と自社プロダクトの横断計測を据えました。次の章からWCAG・WAI-ARIAの順に整理し、最後に自社プロダクトの計測結果へつなげます。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"wcagとは\"\u003eWCAGとは\u003c/h2\u003e\n\u003cp\u003eWCAG（Web Content Accessibility Guidelines）は、W3Cが策定したWebアクセシビリティの国際ガイドラインです。障害のある人や高齢者を含むあらゆるユーザーが、Webコンテンツを利用できるようにするための指針です。\u003c/p\u003e\n\u003ch3 id=\"4つの原則pour\"\u003e4つの原則（POUR）\u003c/h3\u003e\n\u003cp\u003eすべての達成基準は、次の4つの原則に分類できます。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e原則\u003c/th\u003e\n          \u003cth\u003e内容\u003c/th\u003e\n          \u003cth\u003e例\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003ePerceivable（知覚可能）\u003c/td\u003e\n          \u003ctd\u003e情報がユーザーに認識できる\u003c/td\u003e\n          \u003ctd\u003e画像のalt、字幕\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eOperable（操作可能）\u003c/td\u003e\n          \u003ctd\u003eUIが操作できる\u003c/td\u003e\n          \u003ctd\u003eキーボード操作対応\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eUnderstandable（理解可能）\u003c/td\u003e\n          \u003ctd\u003e内容や操作方法が理解できる\u003c/td\u003e\n          \u003ctd\u003eエラー表示の分かりやすさ\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eRobust（堅牢）\u003c/td\u003e\n          \u003ctd\u003e多様な環境で動く\u003c/td\u003e\n          \u003ctd\u003e正しいHTML構造\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch3 id=\"3段階の適合レベル\"\u003e3段階の適合レベル\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eレベル\u003c/th\u003e\n          \u003cth\u003e位置づけ\u003c/th\u003e\n          \u003cth\u003e具体例\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eA\u003c/td\u003e\n          \u003ctd\u003e最低限。これがないと使えない人が出る\u003c/td\u003e\n          \u003ctd\u003eキーボード操作可能、画像にalt属性\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eAA\u003c/td\u003e\n          \u003ctd\u003e一般的に目指すべき標準\u003c/td\u003e\n          \u003ctd\u003eコントラスト比4.5:1以上、200%拡大対応\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eAAA\u003c/td\u003e\n          \u003ctd\u003e最高レベル。完全準拠は現実的に困難\u003c/td\u003e\n          \u003ctd\u003eコントラスト比7:1以上、手話付き動画\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e日本ではJIS X 8341-3:2016がWCAG 2.0と同内容のため、公的機関でも参照されています。実務で目標とする標準ラインは \u003cstrong\u003eAA準拠\u003c/strong\u003e です。\u003c/p\u003e","title":"フロントエンドが押さえたいWebアクセシビリティの基礎 ── WCAG・WAI-ARIAを実プロダクトで試す"},{"content":"1. はじめに こんにちは。and factory バックエンドエンジニアの木梨です。\nClaude Codeを大規模コードベースで使っていると、「この機能はどこで実装されているか」のような広い問いで Grep や Explore が何度も走り、待ち時間が長くなりがちです。私が触っているGo / PHP / JSが混在する大規模モノレポでも、広い問いで数分待たされるのが日常的に発生していました。\n改善策を探していた背景には2つの体験があります。1つは、社内にDevinが導入されたときにDeepWikiでコードベースに広い問いを投げた際の回答速度の速さです。一次情報は見つけられませんでしたが、応答の仕方からして内部でRAGを使っているのではと推測しています。もう1つは、Cursorのブログで報告されているセマンティック検索の導入効果です。どちらも「セマンティック検索を前段に置けば広い問いが軽くなる」という方向を示しています。同じ発想で最小構成のRAGをClaude Codeの前段に置いてみたのが本記事で紹介する構成です。\n本記事では、tree-sitter・Qwen Embedding・ChromaDB で組んだ最小構成の RAG CLI を Claude Code から呼ぶまでを手順として共有します。約220行のPythonで動きます。既存のRAGライブラリを使う選択肢もありました。それでも今回自作の形にしたのは、Claude Code用にCLIとして統一したかった点と、中身が見える最小構成のほうが細かい調整をしやすい点の2つが理由です。\n対象読者は Claude Code を日常的に使っており、RAG の基本概念（埋め込みベクトル、近傍検索）は既知の方を想定しています。\nTL;DR 構成: tree-sitter + Qwen3 Embedding + ChromaDB + Python CLI。約220行 使い方: Claude Codeから検索CLI（例: myrag search）を呼び、候補を Read / Grep で裏取り 効果: 広い問いで待ち時間が体感で明確に短縮された。参考計測では中央値でRAG前段75秒 / Explore very thorough 132秒 / Explore medium 172秒。実行時間の安定性でもRAGが優位。詳細は6章「実際の効果」参照 注意: チャンク化したコードを外部APIへ送る構成。業務利用前に法務・セキュリティ確認が必要。詳細は事前に確認したいことを参照 事前に確認したいこと チャンク化したコードはAlibaba Cloud（DashScope）に送信されます。業務リポジトリで適用する場合は、先に社内の法務・セキュリティ窓口でコード外部送信ポリシーを確認してください。以下は最低限のチェックポイントです。\n検証時は匿名化済みコードのみを使う APIキー、秘密鍵、認証トークン、個人情報、契約情報を含むファイルは投入しない .env や秘密鍵は EXCLUDE_PATTERNS（後述）に含まれているので、社内固有の機密ファイルがあれば同じ要領でパターンを足す 機密性が高くそもそも外部送信を避けたい場合は、embedder.py を sentence-transformers 等のローカル埋め込みに差し替えれば同じ構成で動きます。\n導入判断の目安 手を動かす前に、自分の環境が向いているかをざっくり判定するための表です。\n条件 向いているか 大規模モノレポ（数千ファイル以上）で「どこに実装？」系の広い問いが多い 向いている 関数名・型名・定数名が分かっている検索が中心 Grep で十分なことが多い 外部APIへのコード送信が禁止 本記事のまま動かすのは不可。embedder.py をローカル埋め込みに差し替えて検証を推奨 小規模リポジトリ（目安: 1,000ファイル以下） Explore や Grep の方が軽量で速いため、導入コストに見合わないことが多い Go以外の言語がメインで、関数単位チャンクの精度を上げたい tree-sitter grammarの追加実装が前提になる（4章「実装」参照） 「広い問いで数分待つ頻度が週数回以上」あたりが、約220行のPythonを書く費用対効果の目安感です。\n2. 全体像 構成は単純です。\n① インデックス構築（一度だけ実行）\nflowchart LR A[\u0026#34;source files\u0026#34;] --\u0026gt;|tree-sitter でパース| B[\u0026#34;関数・メソッド単位のチャンク\u0026#34;] B --\u0026gt;|Qwen Embedding でベクトル化| C[(\u0026#34;ChromaDB\u0026#34;)] ② 検索（Claude Code から呼ぶ）\nflowchart LR Q[\u0026#34;query\u0026#34;] --\u0026gt;|Qwen Embedding でベクトル化| R[\u0026#34;クエリベクトル\u0026#34;] R --\u0026gt;|ChromaDB で近傍検索| S[\u0026#34;候補チャンク JSON\u0026#34;] S --\u0026gt;|Read / Grep で裏取り| T[\u0026#34;Claude Code\u0026#34;] 使うもの：\n用途 使うもの ひとことで コードのパース tree-sitter + 言語別パッケージ（tree-sitter-go など） 多言語対応の構文解析ライブラリ。ASTを取り出せる 埋め込みモデル Qwen3 Embedding（text-embedding-v4 / DashScope 経由） Alibaba Cloudの多言語対応埋め込みモデル。DashScopeはそのAPI窓口 ベクトル DB ChromaDB（ローカルファイル永続化） オープンソースの軽量ベクトルDB。ローカルファイルに永続化できるので最小構成向き CLI部分はPython標準ライブラリの argparse だけで作ります（外部依存を増やさない）。\n設計方針: 「候補返し」に徹する このRAGは LLM による回答合成を自前では行いません。候補チャンクのJSONを返すところで止めて、合成や補足検索はClaude Code側に任せます。Claude Codeは Read / Grep / Explore を自律的に呼び出すので、RAG側でも合成を挟むと往復が二重になって逆に遅くなるからです。\n記事全体の実装判断（chunker.py が symbol / start_line を付ける、search.py が候補リストのみ返す等）は、この「候補返しに徹する」方針から来ています。\n3. セットアップ ここでは、手元で動かすための前提を揃えます。以降の実装章を読み進める前に、ここで依存をインストールしておきます。\n必要なもの uv: Pythonとパッケージ管理を兼ねる高速ツール。brew install uv で導入 Python 3.11〜3.13: 言語別tree-sitterパーサの3.14用ホイール未整備のため。uvが自動取得する DashScope API キー: Alibaba Cloudで発行（2026年4月時点で無料枠あり。最新は公式の課金情報ページを参照）。本記事は国際版エンドポイント（dashscope-intl.aliyuncs.com）前提 手順 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 mkdir myrag \u0026amp;\u0026amp; cd myrag # pyproject.toml を作る（依存関係を宣言） cat \u0026gt; pyproject.toml \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; [project] name = \u0026#34;myrag\u0026#34; version = \u0026#34;0.1.0\u0026#34; requires-python = \u0026#34;\u0026gt;=3.11,\u0026lt;3.14\u0026#34; dependencies = [ \u0026#34;tree-sitter\u0026gt;=0.25\u0026#34;, \u0026#34;tree-sitter-go\u0026gt;=0.25\u0026#34;, \u0026#34;chromadb\u0026gt;=1.5\u0026#34;, \u0026#34;dashscope\u0026gt;=1.25\u0026#34;, \u0026#34;pathspec\u0026gt;=1.1\u0026#34;, ] EOF # 仮想環境の作成と依存インストールを一括で uv sync # API キーは環境変数で渡す export DASHSCOPE_API_KEY=\u0026#34;sk-...\u0026#34; 本記事で動作確認したバージョン パッケージ バージョン uv 0.9.x Python 3.13.x tree-sitter 0.25.x tree-sitter-go 0.25.x chromadb 1.5.x dashscope 1.25.x pathspec 1.1.x 4. 実装 ここからの実装は chunker.py / embedder.py / index.py / search.py / cli.py の5ファイルに収まります。チャンク分割 → 埋め込み → ChromaDBへの格納と検索 → CLIの順で作っていきます。\n4.1 チャンク分割 コードをRAGに載せる最初のステップは、チャンク分割です。文書RAGなら段落単位で切りますが、コードRAGでは関数・メソッド単位で切るのが相性の良い方法です。関数は論理的にまとまった単位なので、埋め込みの意味がぶれにくくなります。\nここでは tree-sitter と tree-sitter-go を使い、GoファイルをASTから関数・メソッド単位に分割します。tree-sitter は多言語対応の構文解析ライブラリで、言語別grammarパッケージ（tree-sitter-go など）を差し替えれば他の言語にも広げられます。\n関数・メソッド単位でチャンクを切る最小コード イメージとしては次のような流れです。Goのソースをtree-sitterでパースしてASTを得て、関数・メソッドのノードを拾ってメタデータ付きのチャンクに変換します。\nflowchart LR A[\u0026#34;auth.go（Goコード）\u0026#34;] --\u0026gt;|tree-sitter でパース| B[\u0026#34;AST\u0026#34;] B --\u0026gt;|関数・メソッドノードを走査して切り出し| C[\u0026#34;関数・メソッド単位チャンク\u0026lt;br/\u0026gt;{ path, symbol, start_line,\u0026lt;br/\u0026gt;end_line, language, code }\u0026#34;] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 # chunker.py from tree_sitter import Language, Parser import tree_sitter_go as tsgo # Go 用のパーサ（他言語を足すときは tree_sitter_python などを import してパーサを増やす） GO_LANGUAGE = Language(tsgo.language()) GO_PARSER = Parser(GO_LANGUAGE) def chunk_go_file(path: str) -\u0026gt; list[dict]: \u0026#34;\u0026#34;\u0026#34;Go ファイルを関数・メソッド単位のチャンクに切る\u0026#34;\u0026#34;\u0026#34; with open(path, \u0026#34;rb\u0026#34;) as f: source = f.read() tree = GO_PARSER.parse(source) chunks = [] def visit(node): # AST を深さ優先で走査し、関数/メソッドノードに当たったらチャンク化する # Go では関数（func Foo）とメソッド（func (r *R) Foo）で AST ノード名が違う if node.type in (\u0026#34;function_declaration\u0026#34;, \u0026#34;method_declaration\u0026#34;): # 関数名を取得（匿名関数が混ざっても落ちないよう \u0026#34;anonymous\u0026#34; にフォールバック） name_node = node.child_by_field_name(\u0026#34;name\u0026#34;) name = name_node.text.decode() if name_node else \u0026#34;anonymous\u0026#34; chunks.append({ \u0026#34;path\u0026#34;: path, \u0026#34;symbol\u0026#34;: name, # tree-sitter の行番号は 0 始まりなので +1 して人間が読みやすい形に \u0026#34;start_line\u0026#34;: node.start_point[0] + 1, \u0026#34;end_line\u0026#34;: node.end_point[0] + 1, \u0026#34;language\u0026#34;: \u0026#34;go\u0026#34;, # ノードのバイト範囲からソース該当部分を切り出してチャンク本文に格納 \u0026#34;code\u0026#34;: source[node.start_byte:node.end_byte].decode(\u0026#34;utf-8\u0026#34;, errors=\u0026#34;replace\u0026#34;), }) # 関数リテラル（クロージャ）は外側と内容が重複するため再帰しない return # 関数/メソッドでなければ子ノードを辿って関数宣言を探し続ける for child in node.children: visit(child) visit(tree.root_node) return chunks Goでは関数とメソッドでASTノード名が異なるため、その両方を拾ってチャンク化します。メタデータとして path / symbol / start_line / end_line を付けておくと、後でClaude Codeが Read ツールで該当箇所を読むときに役立ちます。\n1点注意として、関数単位で切る設計では数百行の巨大関数もそのまま1チャンクに収まります。text-embedding-v4 のトークン上限（目安: 8,192）を超えるとAPIエラーが返ります。数百行超の関数を含むリポジトリでは「一定行数超で行ベースに切り替える」fallbackを足すのが安全です。\n他の言語に対応する場合は、pip install tree-sitter-php のようにgrammarパッケージを追加し、対応するParserを作ってASTノード名を増やしていきます。例えばPHPなら function_definition / method_declaration などです。\n他言語向けの行ベース fallback 実リポジトリには他の言語も混在するので、本記事では次の仕様でチャンク分割します。\n.go はtree-sitterで関数・メソッド単位に切る それ以外はN行ずつの固定長チャンクに切る（Markdownや設定ファイルも含め拾う） バイナリや依存ディレクトリ、秘匿ファイルなどはインデックスから除外する 除外は .gitignore 記法のワイルドカード（vendor/, *.min.js, .env.* など）で定義し、pathspec でマッチングします。ディレクトリ単位・拡張子単位・ファイル名パターンを同じ書式で扱えるので、機密ファイルや巨大な依存ディレクトリを漏れなく弾けます。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 # chunker.py（続き） from pathlib import Path import pathspec # 行ベースチャンクのサイズ（関数・メソッド単位より粗いが、全体を拾える） LINES_PER_CHUNK = 40 # インデックスから除外するパターン（.gitignore 記法） EXCLUDE_PATTERNS = [ # 依存・生成物 \u0026#34;vendor/\u0026#34;, \u0026#34;node_modules/\u0026#34;, \u0026#34;bower_components/\u0026#34;, \u0026#34;.git/\u0026#34;, \u0026#34;__pycache__/\u0026#34;, \u0026#34;.venv/\u0026#34;, \u0026#34;dist/\u0026#34;, \u0026#34;build/\u0026#34;, \u0026#34;*.min.js\u0026#34;, \u0026#34;*.min.css\u0026#34;, \u0026#34;*.map\u0026#34;, \u0026#34;*.generated.*\u0026#34;, \u0026#34;*.pyc\u0026#34;, # バイナリ・メディア \u0026#34;*.png\u0026#34;, \u0026#34;*.jpg\u0026#34;, \u0026#34;*.jpeg\u0026#34;, \u0026#34;*.gif\u0026#34;, \u0026#34;*.webp\u0026#34;, \u0026#34;*.ico\u0026#34;, \u0026#34;*.svg\u0026#34;, \u0026#34;*.pdf\u0026#34;, \u0026#34;*.zip\u0026#34;, \u0026#34;*.tar\u0026#34;, \u0026#34;*.gz\u0026#34;, \u0026#34;*.bz2\u0026#34;, \u0026#34;*.mp3\u0026#34;, \u0026#34;*.mp4\u0026#34;, \u0026#34;*.mov\u0026#34;, \u0026#34;*.avi\u0026#34;, \u0026#34;*.so\u0026#34;, \u0026#34;*.dylib\u0026#34;, \u0026#34;*.dll\u0026#34;, \u0026#34;*.exe\u0026#34;, # シークレット系 \u0026#34;.env\u0026#34;, \u0026#34;.env.*\u0026#34;, \u0026#34;*.pem\u0026#34;, \u0026#34;*.key\u0026#34;, \u0026#34;*.p12\u0026#34;, \u0026#34;*.pfx\u0026#34;, \u0026#34;credentials.json\u0026#34;, \u0026#34;serviceAccountKey.json\u0026#34;, \u0026#34;*.secret\u0026#34;, \u0026#34;.secrets/\u0026#34;, ] def chunk_by_lines(path: str) -\u0026gt; list[dict]: \u0026#34;\u0026#34;\u0026#34;ファイルを LINES_PER_CHUNK 行ずつの固定長チャンクに切る\u0026#34;\u0026#34;\u0026#34; with open(path, \u0026#34;r\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;, errors=\u0026#34;ignore\u0026#34;) as f: lines = f.readlines() p = Path(path) # 拡張子がない場合（Makefile / Dockerfile 等）はファイル名を小文字で使う language = p.suffix.lstrip(\u0026#34;.\u0026#34;) or p.name.lower() chunks = [] for i in range(0, len(lines), LINES_PER_CHUNK): block = lines[i:i + LINES_PER_CHUNK] chunks.append({ \u0026#34;path\u0026#34;: path, \u0026#34;symbol\u0026#34;: \u0026#34;\u0026#34;, # 行ベースでは関数名は分からない \u0026#34;start_line\u0026#34;: i + 1, \u0026#34;end_line\u0026#34;: i + len(block), \u0026#34;language\u0026#34;: language, \u0026#34;code\u0026#34;: \u0026#34;\u0026#34;.join(block), }) return chunks def chunk_repo(root: str) -\u0026gt; list[dict]: \u0026#34;\u0026#34;\u0026#34;リポジトリ配下を再帰的に走査してチャンクのリストを返す\u0026#34;\u0026#34;\u0026#34; root_path = Path(root) # EXCLUDE_PATTERNS に加えて、リポ直下の .gitignore も読んで除外に反映 patterns = list(EXCLUDE_PATTERNS) gitignore = root_path / \u0026#34;.gitignore\u0026#34; if gitignore.exists(): patterns.extend(gitignore.read_text(errors=\u0026#34;replace\u0026#34;).splitlines()) spec = pathspec.PathSpec.from_lines(\u0026#34;gitwildmatch\u0026#34;, patterns) chunks = [] for path in root_path.rglob(\u0026#34;*\u0026#34;): if not path.is_file(): continue rel = str(path.relative_to(root_path)) if spec.match_file(rel): continue if path.suffix == \u0026#34;.go\u0026#34;: # Go は関数・メソッド単位で切る（精度が高い） chunks.extend(chunk_go_file(str(path))) else: # それ以外は行ベースに fallback（全体を拾う） chunks.extend(chunk_by_lines(str(path))) return chunks これで chunk_repo(\u0026quot;./my-repo\u0026quot;) を呼ぶと、Goは関数・メソッド単位、それ以外のテキストファイルは40行単位のチャンクとして取得できます。リポジトリ直下に .gitignore があれば自動的に除外対象に加わります。\nなお本実装はリポ直下の .gitignore のみを参照する簡易仕様です。vendor/ や node_modules/ 配下に独自の .gitignore を置く運用では、除外漏れが起きる恐れもあります。回避するには EXCLUDE_PATTERNS に明示パターンを足すか、git check-ignore をサブプロセス経由で使う形に切り替えてください。\n精度を上げたい言語は、後から chunk_go_file と同様の関数を増やして上の分岐に足していけばOKです。\n4.2 埋め込み 本記事では、Alibaba CloudのDashScopeから提供される埋め込みモデル text-embedding-v4 を使用します。これはDashScopeが提供するQwen3 Embeddingファミリの埋め込みAPIで、以降は便宜上「Qwen Embedding」と表記します。弊社はAlibaba Cloud と業務提携しており、社内インフラとしても採用済みのため第一候補として選んでいます。DashScopeが使えない環境では、embedder.py をOpenAI SDK用に差し替えれば text-embedding-3-small などでも動きます（他のファイルは変更不要）。\nDashScopeのPython SDK経由なら、薄いラッパーで書けます。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 # embedder.py import os import dashscope from dashscope import TextEmbedding # 国際版（DashScope Intl.）を使う場合は明示。中国本土版ならこの行は不要 dashscope.base_http_api_url = \u0026#34;https://dashscope-intl.aliyuncs.com/api/v1\u0026#34; # text-embedding-v4 は 1 リクエストあたり最大 10 件の制限がある _BATCH_SIZE = 10 def embed(texts: list[str]) -\u0026gt; list[list[float]]: \u0026#34;\u0026#34;\u0026#34;テキストのリストを埋め込みベクトルのリストに変換する\u0026#34;\u0026#34;\u0026#34; results = [] # API 側の上限（1 リクエスト 10 件）に合わせて分割して送る for i in range(0, len(texts), _BATCH_SIZE): batch = texts[i:i + _BATCH_SIZE] response = TextEmbedding.call( model=\u0026#34;text-embedding-v4\u0026#34;, input=batch, api_key=os.environ[\u0026#34;DASHSCOPE_API_KEY\u0026#34;], ) # output が None の場合はレート制限や認証エラー等。メッセージを付けて止める if response.output is None: raise RuntimeError(f\u0026#34;DashScope error: {response.code} {response.message}\u0026#34;) # レスポンスから embedding ベクトルだけ取り出して結果に積む results.extend(item[\u0026#34;embedding\u0026#34;] for item in response.output[\u0026#34;embeddings\u0026#34;]) return results 4.3 ChromaDB への格納と近傍検索 ChromaDBはオープンソースのベクトルDBで、ローカルファイルに永続化できます。埋め込みベクトルを貯めておき、クエリとの近傍検索（ベクトル空間で距離が近いもの順に取り出す操作）を行うのがここの仕事です。最小構成ではこれで十分です。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 # index.py from pathlib import Path import chromadb from chunker import chunk_repo from embedder import embed DEFAULT_DB_PATH = str(Path(__file__).parent / \u0026#34;chroma_db\u0026#34;) def build_index(repo_path: str, db_path: str = DEFAULT_DB_PATH): \u0026#34;\u0026#34;\u0026#34;リポジトリをチャンク化し、埋め込みベクトルと一緒に ChromaDB に格納する\u0026#34;\u0026#34;\u0026#34; # ローカルファイルに永続化するクライアント client = chromadb.PersistentClient(path=db_path) # リネーム・削除で旧 ID が残らないように毎回コレクションを作り直す if any(c.name == \u0026#34;code_chunks\u0026#34; for c in client.list_collections()): client.delete_collection(name=\u0026#34;code_chunks\u0026#34;) # 類似度比較しやすいよう cosine 距離を指定（距離は 0〜2 の範囲で返ってくる） collection = client.create_collection( name=\u0026#34;code_chunks\u0026#34;, metadata={\u0026#34;hnsw:space\u0026#34;: \u0026#34;cosine\u0026#34;}, ) # ① リポを走査してチャンク化 → ② コード本文だけ取り出し → ③ 埋め込みに変換 chunks = chunk_repo(repo_path) codes = [c[\u0026#34;code\u0026#34;] for c in chunks] vectors = embed(codes) # ベクトル・メタデータ・元コードをまとめて 1 件ずつコレクションに格納 # ID は path + 開始行で一意にする（同じファイルの別チャンクを区別） collection.add( ids=[f\u0026#34;{c[\u0026#39;path\u0026#39;]}:{c[\u0026#39;start_line\u0026#39;]}\u0026#34; for c in chunks], embeddings=vectors, metadatas=[{ \u0026#34;path\u0026#34;: c[\u0026#34;path\u0026#34;], \u0026#34;symbol\u0026#34;: c[\u0026#34;symbol\u0026#34;], \u0026#34;start_line\u0026#34;: c[\u0026#34;start_line\u0026#34;], \u0026#34;end_line\u0026#34;: c[\u0026#34;end_line\u0026#34;], \u0026#34;language\u0026#34;: c[\u0026#34;language\u0026#34;], } for c in chunks], documents=codes, ) print(f\u0026#34;Indexed {len(chunks)} chunks\u0026#34;) build は毎回コレクションを作り直す（全置き換え）仕様にしています。upsert だとファイル名変更や関数削除時に旧IDのエントリが残り続け、ノイズとして検索結果に混ざる事故が起きるためです。差分更新にしたい場合は7章まとめ末の「次のアクション」を参照してください。\n近傍検索 検索側も同じくらいシンプルです。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 # search.py from pathlib import Path import chromadb from embedder import embed DEFAULT_DB_PATH = str(Path(__file__).parent / \u0026#34;chroma_db\u0026#34;) def search(query: str, top_k: int = 5, db_path: str = DEFAULT_DB_PATH) -\u0026gt; list[dict]: \u0026#34;\u0026#34;\u0026#34;クエリ文字列に近いチャンクを top_k 件返す\u0026#34;\u0026#34;\u0026#34; # インデックス構築時に作ったコレクションを読み込む client = chromadb.PersistentClient(path=db_path) collection = client.get_collection(name=\u0026#34;code_chunks\u0026#34;) # クエリも埋め込みに変換してから近傍検索にかける query_vector = embed([query])[0] result = collection.query( query_embeddings=[query_vector], n_results=top_k, ) # ChromaDB の返却は複数クエリ対応の 2 次元配列。今回は単一クエリなので [0] で展開 # メタデータと距離を取り出し、Claude Code が扱いやすい辞書形式に整形する candidates = [] for i in range(len(result[\u0026#34;ids\u0026#34;][0])): meta = result[\u0026#34;metadatas\u0026#34;][0][i] candidates.append({ \u0026#34;path\u0026#34;: meta[\u0026#34;path\u0026#34;], \u0026#34;symbol\u0026#34;: meta[\u0026#34;symbol\u0026#34;], \u0026#34;start_line\u0026#34;: meta[\u0026#34;start_line\u0026#34;], \u0026#34;end_line\u0026#34;: meta[\u0026#34;end_line\u0026#34;], # ChromaDB の cosine 距離は `1 - cos(θ)`（範囲は 0〜2）。 # 「0 が完全一致、2 が完全反対」を「1 が完全一致、0 が完全反対」に # 線形変換したいので、2 で割って 1 から引き 0〜1 のスコアにする。 # 厳密なコサイン類似度ではなく、扱いやすい順位付けスコアとしての定義。 \u0026#34;score\u0026#34;: 1.0 - result[\u0026#34;distances\u0026#34;][0][i] / 2.0, }) return candidates これで、クエリ文字列から類似チャンクのメタデータが返ってきます。score は0〜1の範囲で、1 に近いほど意味が近いという扱いです。\nChromaDBのcosine距離は 1 - cos(θ) で0〜2の範囲を取るため、2で割ってから1から引き、0〜1のスコアに線形変換しています。厳密なコサイン類似度そのものではなく、順位付けに使うスコアとしての定義です。\n4.4 CLI コア機能をCLIにまとめます。インデックス構築と検索の2サブコマンドを argparse で実装します。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 # cli.py import argparse import json import sys from index import build_index from search import search def main(): parser = argparse.ArgumentParser(prog=\u0026#34;myrag\u0026#34;) sub = parser.add_subparsers(dest=\u0026#34;command\u0026#34;, required=True) # インデックス構築サブコマンド: build \u0026lt;repo_path\u0026gt; p_build = sub.add_parser(\u0026#34;build\u0026#34;, help=\u0026#34;インデックスを構築\u0026#34;) p_build.add_argument(\u0026#34;repo_path\u0026#34;) # 検索サブコマンド: search \u0026lt;query\u0026gt; [--top-k N] p_search = sub.add_parser(\u0026#34;search\u0026#34;, help=\u0026#34;類似チャンクを検索\u0026#34;) p_search.add_argument(\u0026#34;query\u0026#34;) p_search.add_argument(\u0026#34;--top-k\u0026#34;, type=int, default=5) args = parser.parse_args() if args.command == \u0026#34;build\u0026#34;: build_index(args.repo_path) elif args.command == \u0026#34;search\u0026#34;: results = search(args.query, top_k=args.top_k) # Claude Code が Bash ツール経由で受け取れるよう JSON を標準出力に書く json.dump({\u0026#34;candidates\u0026#34;: results}, sys.stdout, ensure_ascii=False, indent=2) if __name__ == \u0026#34;__main__\u0026#34;: main() 使い方は次のとおりです。\n1 2 3 4 5 # インデックス構築（最初の 1 回） uv run cli.py build ./my-repo # 検索 uv run cli.py search \u0026#34;ユーザー認証の実装はどこ\u0026#34; --top-k 5 シェルから頻繁に叩くなら、以下のようなエイリアスを貼っておくと myrag search ... の形で呼べます。本文中では以降、このエイリアスを貼った前提で myrag と表記します（Claude Codeから呼ぶときは5章「Claude Codeから使う」のフルコマンド例を貼り付けてください）。\n1 alias myrag=\u0026#39;uv run --project /absolute/path/to/myrag /absolute/path/to/myrag/cli.py\u0026#39; 出力はJSONで返ります。\n1 2 3 4 5 6 7 8 9 10 11 { \u0026#34;candidates\u0026#34;: [ { \u0026#34;path\u0026#34;: \u0026#34;internal/auth/handler.go\u0026#34;, \u0026#34;symbol\u0026#34;: \u0026#34;LoginHandler\u0026#34;, \u0026#34;start_line\u0026#34;: 42, \u0026#34;end_line\u0026#34;: 78, \u0026#34;score\u0026#34;: 0.87 } ] } ここまでで、myrag build と myrag search の2コマンドが揃い、シェルから叩けば候補のJSONが返ってくる最小CLIが完成しました。次章でClaude Codeから呼び出す指示をCLAUDE.mdに書き込みます。\n5. Claude Code から使う Claude Codeからは Bash ツール経由でCLIを呼び、返ってきたJSONを手掛かりに Read / Grep で裏取りしてもらいます。実際には、CLAUDE.mdに方針を書いておくと運用しやすくなります。要点は次の4つです。\n広い問いではまず myrag search を呼ぶ。識別子が分かっている問いは Grep に直行する 日本語クエリと英語クエリを 2〜3 パターン並列で投げる（Qwen Embeddingは多言語対応なのでヒットするファイルが変わる） 候補上位から 3〜5 ファイルを Read で裏取りしてから回答する（問いの広さに応じて調整） 候補が的外れなら Grep / Explore にフォールバック（RAGに固執しない） これで広い問いではRAGを前段に使い、識別子問いでは直接 Grep に行くという使い分けを自然に行ってくれます。実際にCLAUDE.mdに入れた全文は折りたたみで掲載します。\nCLAUDE.md に書いた指示（クリックで展開） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ## コード検索のヒント **概念的なコード調査**（「この機能はどこ？」「〜はどんな条件で動く？」など）のときは、まず以下で候補を絞ってから `Read` で裏取りしてください。 ```bash uv run --project /path/to/myrag /path/to/myrag/cli.py search \u0026#34;\u0026lt;要約したキーワード\u0026gt;\u0026#34; --top-k 5 ``` ### ワークフロー 1. **多角的クエリで検索**: 日本語コメントにヒットするクエリと、英語の変数名・関数名にヒットするクエリの **2〜3 パターンを並列** で投げる。出力は JSON で `path` + `start_line` を含む 2. **結果の充足判定**: 結果が 0 件、または全件スコアが低ければクエリを言い換えて 1 回だけ再実行 3. **ソースコードで裏取り（必須）**: 候補上位から **3〜5 ファイル** を `Read` で確認する（問いの広さに応じて調整）。**条件分岐・ガード節・状態変更・外部呼び出し** を含むコードを優先 4. **回答は 3 観点で整理**: 「**誰が対象**（処理対象の条件）」「**何がトリガー**（発火タイミング）」「**どの条件で**（分岐・ガード節）」を冒頭で明示する 5. **コード引用**: ファイルパスと行番号を必ず含める（例: `auth.go:L185`） ### このコマンドを使わない場面 - 識別子が既知（関数名・型名が分かっている）→ 直接 `Grep` が速い - 未コミット変更の確認 → インデックス外なので `Grep` / `Read` で直接 - 候補のどれも的外れに見える → RAG に固執せず `Grep` や `Explore` にフォールバック 6. 実際の効果 結論から言うと、広い問いではAgentic Search単独よりもRAGを前段に挟んだほうが速く、実行時間も安定しやすいという結果でした。業務で触っているGo/PHP/JSが混在する大規模モノレポ（生成物や依存ディレクトリを除いた実装ファイルが1万件規模）で計測した例です。弊社はAlibaba Cloud と業務提携しており社内インフラとしても採用済みのため、計測時の業務リポジトリへのDashScope適用は社内ポリシー上クリアしています。読者の皆さんが業務リポで試す場合は、本記事冒頭の事前に確認したいことを参照のうえ、社内の法務・セキュリティ窓口へ確認をお願いします。\n先に断っておくと、Claude CodeのサブエージェントはLLM判断で試行ごとに挙動が変わるため、以下の秒数は参考値で、絶対値として扱える水準ではありません。数字そのものより、アプローチ間の相対差と外れ値の出やすさに着目して読んでください。\n計測条件 対象: 上記モノレポのdevelopブランチ 問い: カテゴリの異なる「広い問い」を3問用意 横断列挙系（ある概念のエントリポイントを網羅的に列挙） 業務フロー縦断系（管理画面起点で、バックエンド処理〜配信反映まで追う） 状態遷移系（加入〜退会のように、状態機械の遷移を辿る） アプローチ: 3種を比較 RAG 前段: セマンティック検索でコード候補を返し、Claude Code側で上位候補を Read して裏取りして回答（本記事の最小構成と同じ設計思想） Explore medium: Claude Codeの Explore subagentを thoroughness=medium で起動 Explore very thorough: Claude Codeの Explore subagentを thoroughness=very thorough で起動 試行: 3問 × 3アプローチ × 3試行 = 27 runs を正順で、さらに順序キャッシュバイアス検証用に 逆順 9 runs を追加、合計 36 runs を手動計測 出力要件: 3アプローチで網羅度を揃えるため、同じ字数制約とファイル:行番号の根拠付与を指示 結果（中央値） 9セル（3問 × 3アプローチ）の各中央値を、さらにアプローチ単位で集計した値です。\nアプローチ セル中央値の中央値 セル中央値の範囲 RAG 前段 + Read 裏取り 75 秒 69〜84 Explore very thorough 132 秒 108〜187 Explore medium 172 秒 106〜182 速度だけでなく分散も見ておきます。各アプローチの9試行（3問 × 3試行）の分布を並べると、RAGは幅が狭く、Exploreは広く外れ値も多いのがはっきりします。\n集計の取り方が表ごとに違う点だけ補足しておきます。上表は「セル中央値の中央値」で、下表は「9試行そのものの中央値」です。同じデータでも前者のほうが外れ値の影響を二段階で薄めるため、RAGの中央値は前表75秒・後表78秒と少しずれて見えます。\nアプローチ 最小 中央値 最大 最大/最小比 RAG 前段 67 78 89 1.3倍 Explore very thorough 100 157 319 3.2倍 Explore medium 93 172 707 7.6倍 RAGは9試行すべてが67〜89秒に収まり、Exploreより分散が小さい結果でした。Exploreは外れ値が大きく、広い問いでは試行ごとの探索方針の揺れが実行時間に出やすいように見えます。\n副産物: Explore の medium が very thorough より遅い（2026 年 4 月時点） 直感に反しますが、今回の3問ではいずれもmediumのほうがvery thoroughより時間がかかりました。順序を逆転した計測でも同じ傾向で、サンプル範囲では順序バイアスだけでは説明しきれない差分です。なおClaude Codeのサブエージェントの挙動はバージョン更新で変わることが多いため、以下の分析は2026年4月時点での観察として読んでください。\nサブエージェントの内部ログを追ったところ、以下の違いが観察されました。\n指標（正順 9 試行の中央値） medium very thorough LLM ターン数 41 37 内部ツール呼び出し総数 29 23 grep 系 12 9 find 系 5 7 Read 9 9 読むファイル数は同じですが、medium は grep の反復が多く、very thorough は find で構造を把握してから絞った grep で一撃という挙動になっていました。\n個別ターンを追うと、mediumはenum値を見つけたとき「独立した実装ファイルがあるはず」と深読みしがちでした。存在しないファイルをfind → grep → lsと角度を変えて探し続ける、確信度が上がらないまま同じ情報を取りに行くパターンが頻出します。\n一方でvery thoroughは「switch 文を1発grep」「関連ファイルを並列Read」といった面で情報を取る戦略を自然に選ぶ傾向がありました。\n一般化するにはサンプル不足です。ただし今回の範囲では広い問いでExploreを使うならvery thoroughを一撃で投げたほうが、mediumで様子見するよりも中央値で速く安定しやすいという観察でした。\n計測上の注意 試行数は各セル3回と少なく、外れ値1つで中央値が変わる水準。数字は2倍程度の誤差を見込んで読むのが安全 OS の FS キャッシュ、Claude 側のプロンプトキャッシュの状態でExplore側は特に揺れる（新規セッションで投げ直すと2〜3割遅くなることがある） 「3アプローチで同じ網羅度か」は、提出された回答の挙げたパスの数と主要ファイルの一致度を目視確認して揃えました。ただしLLMの出力なので完全一致は担保できません 使い分けの原則 体感を踏まえると、私は「広い問いはまずRAGを挟み、的外れっぽい候補しか返らなかったらExploreに切り替える」という運用に自然と寄っていきました。\nただしすべての問いで速くなるわけではありません。識別子が分かっている問いは Grep が速いですし、候補が的外れに見えるときは固執せず Explore に戻したほうが結果的に速いです。正味の効果は「毎回速い」ではなく「広い問いでは速く安定、狭い問いでは従来どおり」に近く、使い分ける前提で組むのが実用的です。\n7. まとめ ここまでで、最小構成のコードRAG CLIをClaude Codeから使える状態になりました。構成要素は4つだけです。\ntree-sitter で関数・メソッド単位のチャンク分割 Qwen Embeddingでベクトル化 ChromaDBで近傍検索 Bash ツール経由でClaude Codeから呼ぶ 実装は chunker.py / embedder.py / index.py / search.py / cli.py の5ファイル・合計約220行に収まります。全コードは下の折り畳みに置いておきます。\nコピペ用の全コードを展開する 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 # chunker.py from pathlib import Path import pathspec from tree_sitter import Language, Parser import tree_sitter_go as tsgo # Go 用のパーサ（他言語を足すときは tree_sitter_python などを import してパーサを増やす） GO_LANGUAGE = Language(tsgo.language()) GO_PARSER = Parser(GO_LANGUAGE) def chunk_go_file(path: str) -\u0026gt; list[dict]: \u0026#34;\u0026#34;\u0026#34;Go ファイルを関数・メソッド単位のチャンクに切る\u0026#34;\u0026#34;\u0026#34; with open(path, \u0026#34;rb\u0026#34;) as f: source = f.read() tree = GO_PARSER.parse(source) chunks = [] def visit(node): # AST を深さ優先で走査し、関数/メソッドノードに当たったらチャンク化する # Go では関数（func Foo）とメソッド（func (r *R) Foo）で AST ノード名が違う if node.type in (\u0026#34;function_declaration\u0026#34;, \u0026#34;method_declaration\u0026#34;): # 関数名を取得（匿名関数が混ざっても落ちないよう \u0026#34;anonymous\u0026#34; にフォールバック） name_node = node.child_by_field_name(\u0026#34;name\u0026#34;) name = name_node.text.decode() if name_node else \u0026#34;anonymous\u0026#34; chunks.append({ \u0026#34;path\u0026#34;: path, \u0026#34;symbol\u0026#34;: name, # tree-sitter の行番号は 0 始まりなので +1 して人間が読みやすい形に \u0026#34;start_line\u0026#34;: node.start_point[0] + 1, \u0026#34;end_line\u0026#34;: node.end_point[0] + 1, \u0026#34;language\u0026#34;: \u0026#34;go\u0026#34;, # ノードのバイト範囲からソース該当部分を切り出してチャンク本文に格納 \u0026#34;code\u0026#34;: source[node.start_byte:node.end_byte].decode(\u0026#34;utf-8\u0026#34;, errors=\u0026#34;replace\u0026#34;), }) # 関数リテラル（クロージャ）は外側と内容が重複するため再帰しない return # 関数/メソッドでなければ子ノードを辿って関数宣言を探し続ける for child in node.children: visit(child) visit(tree.root_node) return chunks # 行ベースチャンクのサイズ（関数・メソッド単位より粗いが、全体を拾える） LINES_PER_CHUNK = 40 # インデックスから除外するパターン（.gitignore 記法） EXCLUDE_PATTERNS = [ # 依存・生成物 \u0026#34;vendor/\u0026#34;, \u0026#34;node_modules/\u0026#34;, \u0026#34;bower_components/\u0026#34;, \u0026#34;.git/\u0026#34;, \u0026#34;__pycache__/\u0026#34;, \u0026#34;.venv/\u0026#34;, \u0026#34;dist/\u0026#34;, \u0026#34;build/\u0026#34;, \u0026#34;*.min.js\u0026#34;, \u0026#34;*.min.css\u0026#34;, \u0026#34;*.map\u0026#34;, \u0026#34;*.generated.*\u0026#34;, \u0026#34;*.pyc\u0026#34;, # バイナリ・メディア \u0026#34;*.png\u0026#34;, \u0026#34;*.jpg\u0026#34;, \u0026#34;*.jpeg\u0026#34;, \u0026#34;*.gif\u0026#34;, \u0026#34;*.webp\u0026#34;, \u0026#34;*.ico\u0026#34;, \u0026#34;*.svg\u0026#34;, \u0026#34;*.pdf\u0026#34;, \u0026#34;*.zip\u0026#34;, \u0026#34;*.tar\u0026#34;, \u0026#34;*.gz\u0026#34;, \u0026#34;*.bz2\u0026#34;, \u0026#34;*.mp3\u0026#34;, \u0026#34;*.mp4\u0026#34;, \u0026#34;*.mov\u0026#34;, \u0026#34;*.avi\u0026#34;, \u0026#34;*.so\u0026#34;, \u0026#34;*.dylib\u0026#34;, \u0026#34;*.dll\u0026#34;, \u0026#34;*.exe\u0026#34;, # シークレット系 \u0026#34;.env\u0026#34;, \u0026#34;.env.*\u0026#34;, \u0026#34;*.pem\u0026#34;, \u0026#34;*.key\u0026#34;, \u0026#34;*.p12\u0026#34;, \u0026#34;*.pfx\u0026#34;, \u0026#34;credentials.json\u0026#34;, \u0026#34;serviceAccountKey.json\u0026#34;, \u0026#34;*.secret\u0026#34;, \u0026#34;.secrets/\u0026#34;, ] def chunk_by_lines(path: str) -\u0026gt; list[dict]: \u0026#34;\u0026#34;\u0026#34;ファイルを LINES_PER_CHUNK 行ずつの固定長チャンクに切る\u0026#34;\u0026#34;\u0026#34; with open(path, \u0026#34;r\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;, errors=\u0026#34;ignore\u0026#34;) as f: lines = f.readlines() p = Path(path) # 拡張子がない場合（Makefile / Dockerfile 等）はファイル名を小文字で使う language = p.suffix.lstrip(\u0026#34;.\u0026#34;) or p.name.lower() chunks = [] for i in range(0, len(lines), LINES_PER_CHUNK): block = lines[i:i + LINES_PER_CHUNK] chunks.append({ \u0026#34;path\u0026#34;: path, \u0026#34;symbol\u0026#34;: \u0026#34;\u0026#34;, # 行ベースでは関数名は分からない \u0026#34;start_line\u0026#34;: i + 1, \u0026#34;end_line\u0026#34;: i + len(block), \u0026#34;language\u0026#34;: language, \u0026#34;code\u0026#34;: \u0026#34;\u0026#34;.join(block), }) return chunks def chunk_repo(root: str) -\u0026gt; list[dict]: \u0026#34;\u0026#34;\u0026#34;リポジトリ配下を再帰的に走査してチャンクのリストを返す\u0026#34;\u0026#34;\u0026#34; root_path = Path(root) # EXCLUDE_PATTERNS に加えて、リポ直下の .gitignore も読んで除外に反映 patterns = list(EXCLUDE_PATTERNS) gitignore = root_path / \u0026#34;.gitignore\u0026#34; if gitignore.exists(): patterns.extend(gitignore.read_text(errors=\u0026#34;replace\u0026#34;).splitlines()) spec = pathspec.PathSpec.from_lines(\u0026#34;gitwildmatch\u0026#34;, patterns) chunks = [] for path in root_path.rglob(\u0026#34;*\u0026#34;): if not path.is_file(): continue rel = str(path.relative_to(root_path)) if spec.match_file(rel): continue if path.suffix == \u0026#34;.go\u0026#34;: # Go は関数・メソッド単位で切る（精度が高い） chunks.extend(chunk_go_file(str(path))) else: # それ以外は行ベースに fallback（全体を拾う） chunks.extend(chunk_by_lines(str(path))) return chunks 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 # embedder.py import os import dashscope from dashscope import TextEmbedding # 国際版（DashScope Intl.）を使う場合は明示。中国本土版ならこの行は不要 dashscope.base_http_api_url = \u0026#34;https://dashscope-intl.aliyuncs.com/api/v1\u0026#34; # text-embedding-v4 は 1 リクエストあたり最大 10 件の制限がある _BATCH_SIZE = 10 def embed(texts: list[str]) -\u0026gt; list[list[float]]: \u0026#34;\u0026#34;\u0026#34;テキストのリストを埋め込みベクトルのリストに変換する\u0026#34;\u0026#34;\u0026#34; results = [] # API 側の上限（1 リクエスト 10 件）に合わせて分割して送る for i in range(0, len(texts), _BATCH_SIZE): batch = texts[i:i + _BATCH_SIZE] response = TextEmbedding.call( model=\u0026#34;text-embedding-v4\u0026#34;, input=batch, api_key=os.environ[\u0026#34;DASHSCOPE_API_KEY\u0026#34;], ) # output が None の場合はレート制限や認証エラー等。メッセージを付けて止める if response.output is None: raise RuntimeError(f\u0026#34;DashScope error: {response.code} {response.message}\u0026#34;) # レスポンスから embedding ベクトルだけ取り出して結果に積む results.extend(item[\u0026#34;embedding\u0026#34;] for item in response.output[\u0026#34;embeddings\u0026#34;]) return results 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 # index.py from pathlib import Path import chromadb from chunker import chunk_repo from embedder import embed DEFAULT_DB_PATH = str(Path(__file__).parent / \u0026#34;chroma_db\u0026#34;) def build_index(repo_path: str, db_path: str = DEFAULT_DB_PATH): \u0026#34;\u0026#34;\u0026#34;リポジトリをチャンク化し、埋め込みベクトルと一緒に ChromaDB に格納する\u0026#34;\u0026#34;\u0026#34; # ローカルファイルに永続化するクライアント client = chromadb.PersistentClient(path=db_path) # リネーム・削除で旧 ID が残らないように毎回コレクションを作り直す if any(c.name == \u0026#34;code_chunks\u0026#34; for c in client.list_collections()): client.delete_collection(name=\u0026#34;code_chunks\u0026#34;) # 類似度比較しやすいよう cosine 距離を指定（距離は 0〜2 の範囲で返ってくる） collection = client.create_collection( name=\u0026#34;code_chunks\u0026#34;, metadata={\u0026#34;hnsw:space\u0026#34;: \u0026#34;cosine\u0026#34;}, ) # ① リポを走査してチャンク化 → ② コード本文だけ取り出し → ③ 埋め込みに変換 chunks = chunk_repo(repo_path) codes = [c[\u0026#34;code\u0026#34;] for c in chunks] vectors = embed(codes) # ベクトル・メタデータ・元コードをまとめて 1 件ずつコレクションに格納 # ID は path + 開始行で一意にする（同じファイルの別チャンクを区別） collection.add( ids=[f\u0026#34;{c[\u0026#39;path\u0026#39;]}:{c[\u0026#39;start_line\u0026#39;]}\u0026#34; for c in chunks], embeddings=vectors, metadatas=[{ \u0026#34;path\u0026#34;: c[\u0026#34;path\u0026#34;], \u0026#34;symbol\u0026#34;: c[\u0026#34;symbol\u0026#34;], \u0026#34;start_line\u0026#34;: c[\u0026#34;start_line\u0026#34;], \u0026#34;end_line\u0026#34;: c[\u0026#34;end_line\u0026#34;], \u0026#34;language\u0026#34;: c[\u0026#34;language\u0026#34;], } for c in chunks], documents=codes, ) print(f\u0026#34;Indexed {len(chunks)} chunks\u0026#34;) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 # search.py from pathlib import Path import chromadb from embedder import embed DEFAULT_DB_PATH = str(Path(__file__).parent / \u0026#34;chroma_db\u0026#34;) def search(query: str, top_k: int = 5, db_path: str = DEFAULT_DB_PATH) -\u0026gt; list[dict]: \u0026#34;\u0026#34;\u0026#34;クエリ文字列に近いチャンクを top_k 件返す\u0026#34;\u0026#34;\u0026#34; # インデックス構築時に作ったコレクションを読み込む client = chromadb.PersistentClient(path=db_path) collection = client.get_collection(name=\u0026#34;code_chunks\u0026#34;) # クエリも埋め込みに変換してから近傍検索にかける query_vector = embed([query])[0] result = collection.query( query_embeddings=[query_vector], n_results=top_k, ) # ChromaDB の返却は複数クエリ対応の 2 次元配列。今回は単一クエリなので [0] で展開 # メタデータと距離を取り出し、Claude Code が扱いやすい辞書形式に整形する candidates = [] for i in range(len(result[\u0026#34;ids\u0026#34;][0])): meta = result[\u0026#34;metadatas\u0026#34;][0][i] candidates.append({ \u0026#34;path\u0026#34;: meta[\u0026#34;path\u0026#34;], \u0026#34;symbol\u0026#34;: meta[\u0026#34;symbol\u0026#34;], \u0026#34;start_line\u0026#34;: meta[\u0026#34;start_line\u0026#34;], \u0026#34;end_line\u0026#34;: meta[\u0026#34;end_line\u0026#34;], # ChromaDB の cosine 距離は `1 - cos(θ)`（範囲は 0〜2）。 # 0〜2 のまま扱いにくいので、2 で割って 1 から引き 0〜1 のスコアに線形変換する。 \u0026#34;score\u0026#34;: 1.0 - result[\u0026#34;distances\u0026#34;][0][i] / 2.0, }) return candidates 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 # cli.py import argparse import json import sys from index import build_index from search import search def main(): parser = argparse.ArgumentParser(prog=\u0026#34;myrag\u0026#34;) sub = parser.add_subparsers(dest=\u0026#34;command\u0026#34;, required=True) # インデックス構築サブコマンド: build \u0026lt;repo_path\u0026gt; p_build = sub.add_parser(\u0026#34;build\u0026#34;, help=\u0026#34;インデックスを構築\u0026#34;) p_build.add_argument(\u0026#34;repo_path\u0026#34;) # 検索サブコマンド: search \u0026lt;query\u0026gt; [--top-k N] p_search = sub.add_parser(\u0026#34;search\u0026#34;, help=\u0026#34;類似チャンクを検索\u0026#34;) p_search.add_argument(\u0026#34;query\u0026#34;) p_search.add_argument(\u0026#34;--top-k\u0026#34;, type=int, default=5) args = parser.parse_args() if args.command == \u0026#34;build\u0026#34;: build_index(args.repo_path) elif args.command == \u0026#34;search\u0026#34;: results = search(args.query, top_k=args.top_k) # Claude Code が Bash ツール経由で受け取れるよう JSON を標準出力に書く json.dump({\u0026#34;candidates\u0026#34;: results}, sys.stdout, ensure_ascii=False, indent=2) if __name__ == \u0026#34;__main__\u0026#34;: main() 次のアクション ここから先は本番運用や大規模リポジトリで効いてくる改善です。個人検証や小さめのリポジトリなら、まずは本文の構成だけでも十分試せます。以下はカテゴリ別の改善候補で、順番に足していけばよく、最初から全部揃える必要はありません。\n信頼性\nリトライ・バックオフ: embedder.py はレート制限（HTTP 429）やネットワーク瞬断に対する再試行をしない。数万チャンクのbuildが途中で落ちると初回コストが痛いので、指数バックオフ＋タイムアウトを入れる バッチ化された collection.add: 数万チャンクを一度に add するとメモリとChromaDB側のトランザクションで詰まる可能性がある。512〜2,048件程度に分割してループで投入する パフォーマンス\n差分更新: 毎回フル再インデックスは重い。ファイルハッシュで変更検出し、変更・追加・削除のみ反映する（collection.delete(ids=...) と add の組み合わせ） 並列化: 直列10件バッチだと時間がかかる。ThreadPoolExecutor で5〜10並列にするとインデックス構築は体感半分以下になる（DashScopeのレート制限とバランスを取る） 検索精度・カバレッジ\nチャンク設計の改善: 本記事の chunk_by_lines は40行固定・オーバーラップなし。関数途中で切れた箇所は意味が分断されるので、チャンク間のオーバーラップ（5〜10行）や、巨大関数を一定行数で分割するfallbackを入れる 多言語対応: Go以外（PHP、TypeScriptなど）に tree-sitter-* パーサを足し、言語別のノード名で関数・メソッドを切る グラフ拡張: ベクトル近傍に加えて、関数呼び出しやimport関係を辿って関連チャンクを補強する。意味的に近いが構造的に遠い問題に効く プロンプトチューニング: 検索クエリの組み立て、CLAUDE.mdの指示文、問い分類ロジックなど、Claude Code側のプロンプトを詰めると検索精度とツール使い分けの挙動が変わる 運用\nチャンクキャッシュの置き場所: chroma_db/ をリポジトリ直下に置くとうっかりcommitされる事故がある。.gitignore に追加するか、~/.cache/myrag/ などユーザーキャッシュに逃がす まずは動く形を手元に置き、日常の調査で困ったポイントから順に機能を足していくのが現実的です。本記事がその最初の約220行を用意する助けになれば幸いです。\n付録: 用語集 記事で使っている技術用語を軽く整理します。\n用語 意味 Embedding（埋め込み） テキストを高次元ベクトルに変換したもの。意味が近いテキストは近いベクトルになる 近傍検索 ベクトル間の距離を比較して「近い」ものを取り出す検索 cosine 距離 1 − cosine類似度 で定義される指標。類似度が −1〜1 の範囲を取るため距離は 0〜2 で、0 に近いほど意味が似ている チャンク 検索対象のテキストを一定サイズに切り分けた断片 AST（抽象構文木） ソースコードを文法構造として木で表現したもの。tree-sitter が生成する Agentic Search LLM がその場で Grep / Read などのツールを呼んでコードを辿る検索スタイル。Claude Code 標準 関連リンク tree-sitter — 構文解析ライブラリ公式 py-tree-sitter — tree-sitterのPythonバインディング Chroma — オープンソースのベクトルDB Alibaba Cloud Model Studio: Generate text embeddings via the synchronous API — 国際版DashScopeの英語ドキュメント（text-embedding-v4 / Qwen3 Embedding） ","permalink":"https://andfactory.co.jp/techblog/posts/claude-code-custom-rag","summary":"\u003ch2 id=\"1-はじめに\"\u003e1. はじめに\u003c/h2\u003e\n\u003cp\u003eこんにちは。and factory バックエンドエンジニアの木梨です。\u003c/p\u003e\n\u003cp\u003eClaude Codeを大規模コードベースで使っていると、「この機能はどこで実装されているか」のような広い問いで \u003ccode\u003eGrep\u003c/code\u003e や \u003ccode\u003eExplore\u003c/code\u003e が何度も走り、待ち時間が長くなりがちです。私が触っているGo / PHP / JSが混在する大規模モノレポでも、広い問いで数分待たされるのが日常的に発生していました。\u003c/p\u003e\n\u003cp\u003e改善策を探していた背景には2つの体験があります。1つは、社内にDevinが導入されたときに\u003ca href=\"https://deepwiki.com\"\u003eDeepWiki\u003c/a\u003eでコードベースに広い問いを投げた際の回答速度の速さです。一次情報は見つけられませんでしたが、応答の仕方からして内部でRAGを使っているのではと推測しています。もう1つは、\u003ca href=\"https://cursor.com/ja/blog/semsearch\"\u003eCursorのブログ\u003c/a\u003eで報告されているセマンティック検索の導入効果です。どちらも「セマンティック検索を前段に置けば広い問いが軽くなる」という方向を示しています。同じ発想で最小構成のRAGをClaude Codeの前段に置いてみたのが本記事で紹介する構成です。\u003c/p\u003e\n\u003cp\u003e本記事では、\u003cstrong\u003etree-sitter・Qwen Embedding・ChromaDB で組んだ最小構成の RAG CLI を Claude Code から呼ぶ\u003c/strong\u003eまでを手順として共有します。約220行のPythonで動きます。既存のRAGライブラリを使う選択肢もありました。それでも今回自作の形にしたのは、Claude Code用にCLIとして統一したかった点と、中身が見える最小構成のほうが細かい調整をしやすい点の2つが理由です。\u003c/p\u003e\n\u003cp\u003e対象読者は \u003cstrong\u003eClaude Code を日常的に使っており、RAG の基本概念（埋め込みベクトル、近傍検索）は既知\u003c/strong\u003eの方を想定しています。\u003c/p\u003e\n\u003ch3 id=\"tldr\"\u003eTL;DR\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e構成\u003c/strong\u003e: tree-sitter + Qwen3 Embedding + ChromaDB + Python CLI。約220行\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e使い方\u003c/strong\u003e: Claude Codeから検索CLI（例: \u003ccode\u003emyrag search\u003c/code\u003e）を呼び、候補を \u003ccode\u003eRead\u003c/code\u003e / \u003ccode\u003eGrep\u003c/code\u003e で裏取り\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e効果\u003c/strong\u003e: 広い問いで\u003cstrong\u003e待ち時間が体感で明確に短縮\u003c/strong\u003eされた。参考計測では中央値でRAG前段75秒 / Explore very thorough 132秒 / Explore medium 172秒。実行時間の安定性でもRAGが優位。詳細は6章「実際の効果」参照\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e注意\u003c/strong\u003e: チャンク化したコードを外部APIへ送る構成。業務利用前に法務・セキュリティ確認が必要。詳細は\u003ca href=\"#%E4%BA%8B%E5%89%8D%E3%81%AB%E7%A2%BA%E8%AA%8D%E3%81%97%E3%81%9F%E3%81%84%E3%81%93%E3%81%A8\"\u003e事前に確認したいこと\u003c/a\u003eを参照\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"事前に確認したいこと\"\u003e事前に確認したいこと\u003c/h3\u003e\n\u003cp\u003eチャンク化したコードはAlibaba Cloud（DashScope）に送信されます。業務リポジトリで適用する場合は、先に\u003cstrong\u003e社内の法務・セキュリティ窓口でコード外部送信ポリシーを確認\u003c/strong\u003eしてください。以下は最低限のチェックポイントです。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e検証時は匿名化済みコードのみを使う\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAPIキー、秘密鍵、認証トークン、個人情報、契約情報を含むファイルは投入しない\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e.env\u003c/code\u003e や秘密鍵は \u003ccode\u003eEXCLUDE_PATTERNS\u003c/code\u003e（後述）に含まれているので、社内固有の機密ファイルがあれば同じ要領でパターンを足す\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e機密性が高くそもそも外部送信を避けたい場合は、\u003ccode\u003eembedder.py\u003c/code\u003e を \u003ccode\u003esentence-transformers\u003c/code\u003e 等のローカル埋め込みに差し替えれば同じ構成で動きます。\u003c/p\u003e","title":"Claude Code を補強する RAG を自作してみた"},{"content":"はじめに こんにちは。and factory Androidエンジニアの鬼倉です。\n今回は、私が携わるAndroidプロジェクトでClaude Codeを活用し、ANR改善に取り組んだアプローチを紹介します。ANRの原因はさまざまですが、本記事ではメモリリークを原因とするANRに焦点を当てます。\nAndroid開発者にとって、メモリリーク対応のためにLeakCanaryを導入したはいいものの、結局修正できず通知が出続けているという方も多いのではないでしょうか？\nそこで今回私のプロジェクトでは、LeakCanaryによる検知に加えてClaude CodeのSkillsを活用した自動修正の仕組みを構築しましたので紹介します。\nANRとは？\nANR（Application Not Responding）とは、アプリが指定時間内に操作を応答できなかった場合に発生するシステムエラーです。代表的には、UIスレッドが入力イベントに対して5秒以内に応答できない場合などでトリガーされます（BroadcastReceiverやServiceでも発生）。デッドロック、I/O処理のブロック、高負荷処理などさまざまな原因で発生します。Crashと同様にユーザー体験を大きく損なう問題ですが、Crashほど原因が明確でなく再現も難しいため軽視されがちです。ANR率はFirebase CrashlyticsやGoogle Play Consoleで確認できます。\n本記事の環境 技術 バージョン Kotlin 2.3.20 AGP（Android Gradle Plugin） 8.9.3 LeakCanary 2.14 ANR改善が進まなかった背景 まずはそもそもメモリリークによるANRの修正改善がなぜ進んでいなかったのかを整理します。\nFirebaseのANRレポートだけでは原因を深掘りしにくい Crashの場合、Firebase Crashlyticsに発生元のExceptionが記録されるため、その箇所が直接的な修正対象になります。一方、メモリリーク由来のANRは事情が異なります。複数箇所のメモリリークが徐々に蓄積し、最終的にANRとして発生します。そのため、Firebase上のレポートから直接的な原因箇所を特定することが困難です。\nさらに、メモリリークの蓄積は端末のメモリ状況やユーザーの操作パターンに依存するため、開発環境での再現も難しいという問題があります。\nANR改善のためのLeakCanary導入と修正対応の難しさ ANRの発生件数を改善するため、メモリリーク検知の定番ライブラリであるLeakCanaryを導入しました。LeakCanaryを導入すると、開発中の手元の端末上でメモリリークの発生をリアルタイムに把握できます。\nしかし、検知できることと修正できることは別の問題です。LeakCanaryはメモリリークの発生タイミングやある程度の情報を提供しますが、明確なコード上の原因までは教えてくれません。開発者自身が情報をもとに原因となるコードを探し出し、修正する必要があります。\nさらに厄介なのは、メモリリークの原因となるコードは一見すると問題がないように見える点です。修正にはAndroid開発やKotlinに関する深い知識が求められ、1件あたりの対応に時間を要します。結果として、他の機能開発やCrash対応に比べて優先度が下がり、改善が後回しになりがちな状況が続いていました。\nClaude Codeを活用したメモリリーク改善アプローチ 以上の理由により、LeakCanaryやFirebaseのANRログだけでは自力での修正は困難でした。それに対してどのようにAIを活用して修正をしていくのでしょうか？\n最もシンプルなアプローチとしては、LeakCanaryが通知を出したら内容をClaude Codeにコピーペーストして修正を依頼することです。この方法でももちろん対応できます。\nしかし、Logcatを含めた前後情報があればより精度が高まりますし、コピーペーストという作業をなるべく減らし即座に修正を依頼する環境を構築しなければ、再び後回しになりかねません。\n今回私たちが構築したのは、LeakCanaryが通知を出した瞬間にClaude Codeへ/investigate-leakと依頼するだけで完結する方法です。AIが自ら端末のLogcatを確認し、ログ情報からメモリリークの修正を提案します。\nLeakCanaryのリークトレースをClaude Codeから取得可能にする なお、本記事のアプローチではLogcatの内容をAIに読み取らせるため、ユーザーの個人情報やAPIキーといった機密情報がLogに出力されていないことが前提となります。\nLeakCanaryはメモリリークを検知すると端末上に通知を表示します。しかし、デフォルトではヒープダンプの解析結果がlogcatに出力されません。Claude Codeがリークトレースを自律的に取得できるよう、解析結果をlogcatに出力する仕組みを追加しました。\n具体的には、LeakCanaryのonHeapAnalyzedListenerをカスタマイズしています。\n1 2 3 4 5 6 7 8 9 10 11 12 13 import leakcanary.LeakCanary import leakcanary.OnHeapAnalyzedListener import timber.log.Timber val defaultListener = LeakCanary.config.onHeapAnalyzedListener LeakCanary.config = LeakCanary.config.copy( onHeapAnalyzedListener = OnHeapAnalyzedListener { heapAnalysis -\u0026gt; defaultListener.onHeapAnalyzed(heapAnalysis) // ここでLogに出力（ここではTimberを利用しています） Timber.tag(\u0026#34;LeakCanary\u0026#34;).d(heapAnalysis.toString()) } ) この設定により、Claude Codeは以下のadbコマンドでリークトレース全文を取得できます。\n1 adb logcat -d -s LeakCanary なお、LeakCanaryは debugImplementation で導入するため、このクラスは debug ソースセットに配置してください。main ソースセットに置くと、releaseビルド時にLeakCanaryのクラスが見つからずビルドエラーになります。\nClaude Code Skillsでメモリリーク調査を自動化する 次に、LeakCanaryが検知したメモリリークの調査から修正までを一貫して実行するClaude Code Skill（investigate-leak）を作成しました。\nこのSkillは、以下のフローで動作します。\nflowchart LR A[LeakCanary\\n通知発生] --\u003e B[端末接続\\n確認] B --\u003e C[リークトレース\\n取得] C --\u003e D[リークトレース\\n解析] D --\u003e E[コードベース\\n照合] E --\u003e F[修正プラン\\n提示] F --\u003e G[修正の\\n実装] 端末接続の確認 — adb devicesで接続状態を確認する リークトレースの取得 — adb logcat -d -s LeakCanaryでログを取得する リークトレースの解析 — リファレンスチェーンからリーク原因の参照を特定する コードベースとの照合 — リークに関連するクラスやフィールドをコードベースから検索し、根本原因を特定する 修正プランの提示 — 調査結果と具体的な修正内容をまとめて提示する 修正の実装 — ユーザーが承認した場合、修正を実装する 開発者は、LeakCanaryの通知が出たタイミングでClaude Code上から/investigate-leakを実行するだけで、ログの取得から原因特定、修正プランの提示までが自動的に進みます。\nSkillのポイント: リークトレースの取得 Skill内では、adbコマンドを使ってリークトレースを取得する手順を定義しています。\n1 adb logcat -d -s LeakCanary | tail -500 Skillにこの手順を記載することで、Claude Codeが直接Logcatを参照します。開発者がリークトレースをコピーペーストする手間を省き、即座に調査を開始できる仕組みです。\nまた、リークトレースの読み方もSkill内に定義しています。以下はその一部です。\n1 2 3 4 5 6 7 8 9 10 ┬─── │ GC Root: System class │ ├─ com.example.SomeClass instance │ Leaking: NO (...) │ ↓ SomeClass.listener ← この参照が原因 │ ~~~~~~~~ ← 波線が原因箇所を示す ├─ com.example.LeakedActivity instance │ Leaking: YES (Activity#mDestroyed is true) ╰─── このようにLeakCanaryのログ形式もSkillに記載することで、解析精度を高めています。\nまた、よくあるリークパターンと典型的な修正方法もSkill内に定義しており、パターンマッチによる迅速な原因特定と修正を可能にしています。\n導入して変わったこと 今まで放置されていたメモリリークを、このSkillの導入後わずか数日で3箇所修正できました。すでに開発環境で操作していてもLeakCanaryの通知が発生しなくなっています。\nさらに、新規実装時にLeakCanaryが反応した場合でも即座に修正を依頼できるため、メモリリークのないクリーンな状態を維持しながら開発を進められるようになりました。\nまとめ メモリリーク由来のANRは原因特定が難しく、改善が後回しになりやすい LeakCanaryで検知はできるが、修正には深い知識と時間が必要 LeakCanaryの解析結果をlogcatに出力する仕組みを追加し、Claude Codeがadb経由でリークトレースを自律的に取得できるようにした Claude Code Skillsで検知から修正プラン提示・実装までを自動化した 導入後数日で3箇所のメモリリークを修正し、新規実装時もクリーンな状態を維持できるようになった なお、Skill本体のコードは本記事では掲載していませんが、紹介したコンセプトと設計方針をもとにすれば同様の仕組みはゼロから構築可能です。\nLeakCanaryを導入しているものの改善が進んでいないプロジェクトも多いのではないでしょうか。現在であればAIを活用することで、スムーズにメモリリークの修正を進められます。ぜひClaude Codeを活用してANR改善に取り組んでみてください。\n","permalink":"https://andfactory.co.jp/techblog/posts/android-anr-improvement-with-claude-code","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eこんにちは。and factory Androidエンジニアの鬼倉です。\u003c/p\u003e\n\u003cp\u003e今回は、私が携わるAndroidプロジェクトでClaude Codeを活用し、ANR改善に取り組んだアプローチを紹介します。ANRの原因はさまざまですが、本記事ではメモリリークを原因とするANRに焦点を当てます。\u003c/p\u003e\n\u003cp\u003eAndroid開発者にとって、メモリリーク対応のためにLeakCanaryを導入したはいいものの、結局修正できず通知が出続けているという方も多いのではないでしょうか？\u003c/p\u003e\n\u003cp\u003eそこで今回私のプロジェクトでは、LeakCanaryによる検知に加えてClaude CodeのSkillsを活用した自動修正の仕組みを構築しましたので紹介します。\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cstrong\u003eANRとは？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eANR（Application Not Responding）とは、アプリが指定時間内に操作を応答できなかった場合に発生するシステムエラーです。代表的には、UIスレッドが入力イベントに対して5秒以内に応答できない場合などでトリガーされます（BroadcastReceiverやServiceでも発生）。デッドロック、I/O処理のブロック、高負荷処理などさまざまな原因で発生します。Crashと同様にユーザー体験を大きく損なう問題ですが、Crashほど原因が明確でなく再現も難しいため軽視されがちです。ANR率はFirebase CrashlyticsやGoogle Play Consoleで確認できます。\u003c/p\u003e\n\u003chr\u003e\n\u003ch3 id=\"本記事の環境\"\u003e本記事の環境\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e技術\u003c/th\u003e\n          \u003cth\u003eバージョン\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eKotlin\u003c/td\u003e\n          \u003ctd\u003e2.3.20\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eAGP（Android Gradle Plugin）\u003c/td\u003e\n          \u003ctd\u003e8.9.3\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eLeakCanary\u003c/td\u003e\n          \u003ctd\u003e2.14\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch2 id=\"anr改善が進まなかった背景\"\u003eANR改善が進まなかった背景\u003c/h2\u003e\n\u003cp\u003eまずはそもそもメモリリークによるANRの修正改善がなぜ進んでいなかったのかを整理します。\u003c/p\u003e\n\u003ch3 id=\"firebaseのanrレポートだけでは原因を深掘りしにくい\"\u003eFirebaseのANRレポートだけでは原因を深掘りしにくい\u003c/h3\u003e\n\u003cp\u003eCrashの場合、Firebase Crashlyticsに発生元のExceptionが記録されるため、その箇所が直接的な修正対象になります。一方、メモリリーク由来のANRは事情が異なります。複数箇所のメモリリークが徐々に蓄積し、最終的にANRとして発生します。そのため、Firebase上のレポートから直接的な原因箇所を特定することが困難です。\u003c/p\u003e\n\u003cp\u003eさらに、メモリリークの蓄積は端末のメモリ状況やユーザーの操作パターンに依存するため、開発環境での再現も難しいという問題があります。\u003c/p\u003e\n\u003ch3 id=\"anr改善のためのleakcanary導入と修正対応の難しさ\"\u003eANR改善のためのLeakCanary導入と修正対応の難しさ\u003c/h3\u003e\n\u003cp\u003eANRの発生件数を改善するため、メモリリーク検知の定番ライブラリである\u003ca href=\"https://square.github.io/leakcanary/\"\u003eLeakCanary\u003c/a\u003eを導入しました。LeakCanaryを導入すると、開発中の手元の端末上でメモリリークの発生をリアルタイムに把握できます。\u003c/p\u003e\n\u003cp\u003eしかし、検知できることと修正できることは別の問題です。LeakCanaryはメモリリークの発生タイミングやある程度の情報を提供しますが、明確なコード上の原因までは教えてくれません。開発者自身が情報をもとに原因となるコードを探し出し、修正する必要があります。\u003c/p\u003e\n\u003cp\u003eさらに厄介なのは、メモリリークの原因となるコードは一見すると問題がないように見える点です。修正にはAndroid開発やKotlinに関する深い知識が求められ、1件あたりの対応に時間を要します。結果として、他の機能開発やCrash対応に比べて優先度が下がり、改善が後回しになりがちな状況が続いていました。\u003c/p\u003e\n\u003ch2 id=\"claude-codeを活用したメモリリーク改善アプローチ\"\u003eClaude Codeを活用したメモリリーク改善アプローチ\u003c/h2\u003e\n\u003cp\u003e以上の理由により、LeakCanaryやFirebaseのANRログだけでは自力での修正は困難でした。それに対してどのようにAIを活用して修正をしていくのでしょうか？\u003c/p\u003e\n\u003cp\u003e最もシンプルなアプローチとしては、LeakCanaryが通知を出したら内容をClaude Codeにコピーペーストして修正を依頼することです。この方法でももちろん対応できます。\u003c/p\u003e\n\u003cp\u003eしかし、Logcatを含めた前後情報があればより精度が高まりますし、コピーペーストという作業をなるべく減らし即座に修正を依頼する環境を構築しなければ、再び後回しになりかねません。\u003c/p\u003e\n\u003cp\u003e今回私たちが構築したのは、LeakCanaryが通知を出した瞬間にClaude Codeへ\u003ccode\u003e/investigate-leak\u003c/code\u003eと依頼するだけで完結する方法です。AIが自ら端末のLogcatを確認し、ログ情報からメモリリークの修正を提案します。\u003c/p\u003e\n\u003ch3 id=\"leakcanaryのリークトレースをclaude-codeから取得可能にする\"\u003eLeakCanaryのリークトレースをClaude Codeから取得可能にする\u003c/h3\u003e\n\u003cp\u003eなお、本記事のアプローチではLogcatの内容をAIに読み取らせるため、ユーザーの個人情報やAPIキーといった機密情報がLogに出力されていないことが前提となります。\u003c/p\u003e\n\u003cp\u003eLeakCanaryはメモリリークを検知すると端末上に通知を表示します。しかし、デフォルトではヒープダンプの解析結果がlogcatに出力されません。Claude Codeがリークトレースを自律的に取得できるよう、解析結果をlogcatに出力する仕組みを追加しました。\u003c/p\u003e\n\u003cp\u003e具体的には、LeakCanaryの\u003ccode\u003eonHeapAnalyzedListener\u003c/code\u003eをカスタマイズしています。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cdiv style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\n\u003ctable style=\"border-spacing:0;padding:0;margin:0;border:0;\"\u003e\u003ctr\u003e\u003ctd style=\"vertical-align:top;padding:0;margin:0;border:0;\"\u003e\n\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 1\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 2\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 3\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 4\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 5\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 6\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 7\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 8\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 9\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e10\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e11\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e12\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e13\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/td\u003e\n\u003ctd style=\"vertical-align:top;padding:0;margin:0;border:0;;width:100%\"\u003e\n\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-kotlin\" data-lang=\"kotlin\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eimport\u003c/span\u003e leakcanary.LeakCanary\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eimport\u003c/span\u003e leakcanary.OnHeapAnalyzedListener\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eimport\u003c/span\u003e timber.log.Timber\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eval\u003c/span\u003e defaultListener = \u003cspan style=\"color:#a6e22e\"\u003eLeakCanary\u003c/span\u003e.config.onHeapAnalyzedListener\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eLeakCanary\u003c/span\u003e.config = \u003cspan style=\"color:#a6e22e\"\u003eLeakCanary\u003c/span\u003e.config.copy(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    onHeapAnalyzedListener = OnHeapAnalyzedListener { heapAnalysis \u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        defaultListener.onHeapAnalyzed(heapAnalysis)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#75715e\"\u003e// ここでLogに出力（ここではTimberを利用しています）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eTimber\u003c/span\u003e.tag(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;LeakCanary\u0026#34;\u003c/span\u003e).d(heapAnalysis.toString())\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/table\u003e\n\u003c/div\u003e\n\u003c/div\u003e\u003cp\u003eこの設定により、Claude Codeは以下のadbコマンドでリークトレース全文を取得できます。\u003c/p\u003e","title":"Claude CodeでAndroidのメモリリーク改善を自動化する ── LeakCanaryの検知からAI修正までのアプローチ"},{"content":"（2026/6/9追記） 本記事の公開後にセキュリティ運用の見直しを行い、§2と §4にそれぞれ追記を加えています。最新の推奨構成は各セクションの追記をご確認ください。（追記ここまで）\nBetter Auth が Auth.js を公式に引き継いだ発表 2025年9月付近で、Auth.js（旧NextAuth.js、以下NextAuth）の公式発表がありました。Better Auth（npmパッケージ名: better-auth、以下better-auth）チームに引き継がれるという内容です。\n発表された概要は以下のとおりです。\nAuth.jsのセキュリティ修正やクリティカルな対応は継続される 新機能・今後の進化は Better Auth 側に集約される NextAuthの開発メンバーが関わる形でBetter Authへ収束していく方針です。\n既存のプロジェクトであればすぐさまbetter-authに切り替える必要はありませんが、新規のプロジェクトであればbetter-authも有力な選択肢です。\n今回、新規プロジェクトの立ち上げにあたり、この公式発表を受けてbetter-authを採用しました。NextAuth v5では jwt コールバックにリフレッシュ処理を集約していましたが、ロジックの肥大化により保守が困難になっていました。better-authのプラグインベースの設計に魅力を感じたことも決め手の1つです。\nこのドキュメントは、Next.js App Router と TanStack Query を採用したプロジェクトを対象としています。バックエンドAPIが「JWT ＋ リフレッシュトークン」を発行するステートレスな構成において、NextAuth (v5) からbetter-authへ移行する際のノウハウを逆引き形式でまとめました。\nこれからbetter-authの導入や移行を検討しているエンジニアの方は、ぜひ参考にしてください。\n動作確認環境 ライブラリ バージョン Next.js 16.0.7 React 19.x better-auth 1.4.18 @tanstack/react-query 5.75.4 Node.js 24.11.1 1. 初期設定・ルーティング編 Q. フロントエンドでセッション情報を共有したい（Providerの配置） NextAuth: ルートレイアウトなどに \u0026lt;SessionProvider\u0026gt; を配置してReact Contextでセッションを共有する必要がありました。\nbetter-auth: Providerは一切不要（削除）です。better-authはCookieベースで動作し、内部的に nanostores のAtomを利用して各コンポーネントに状態を共有します。\n補足: なぜ Provider なしで動くのか？ NextAuthはReact Context（Provider → Consumer）でセッションを配信していました。better-authはReactの外にあるグローバルストア（nanostoresのAtom）にセッション状態を保持し、useSession() はそのAtomをsubscribeするだけです。Reactツリーに依存しないためProviderが不要になります。初回マウント時にCookieから /api/auth/get-session をfetchしてAtomを初期化し、以降はAtomの値を返します。\nQ. API Route のエンドポイントを作りたい NextAuth: src/app/api/auth/[...nextauth]/route.ts に配置していました。\nbetter-auth: ディレクトリ名を [...all] に変更し、src/app/api/auth/[...all]/route.ts とします。中身は toNextJsHandler を用いてエクスポートします。\n2. 型定義・スキーマ編 初期設定が整ったところで、次に型定義の変更点を見ていきます。\nQ. セッションに独自のカスタムフィールドを追加して型推論させたい NextAuth: next-auth.d.ts を作成し、declare module によるモジュール拡張（Module Augmentation）で型を後付けする必要がありました。ロジックと型定義が分離しがちでした。\nbetter-auth: プラグインの schema が型の「Source of Truth」です。クライアント側で inferAdditionalFields\u0026lt;typeof auth\u0026gt;() を使うだけで、戻り値にカスタムフィールドの型が 自動推論（Schema Inference） されます。.d.ts ファイルを手動更新する手間は不要です。\nQ. セッションオブジェクトの構造はどう変わる？ NextAuthではセッションオブジェクトがフラットな構造でした。better-authでは session と user がネストされた構造に変わります。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // ===== NextAuth ===== { user: { name, email, image }, accessToken: \u0026#34;eyJhbG...\u0026#34;, expires: \u0026#34;2026-03-20T...\u0026#34; } // ===== better-auth ===== { session: { id: \u0026#34;uuid\u0026#34;, userId: \u0026#34;user-id\u0026#34;, accessToken: \u0026#34;eyJhbG...\u0026#34;, // プラグインで追加したフィールド refreshToken: \u0026#34;eyJhbG...\u0026#34;, accessTokenExpiresAt: 1772519329, // ATのJWTをデコードして得た exp refreshTokenExpiresAt: 1772519629, // RTのJWTをデコードして得た exp（自前で追加するフィールド） }, user: { id: \u0026#34;user-id\u0026#34;, name: \u0026#34;user-id\u0026#34;, email: \u0026#34;user-id@placeholder.local\u0026#34;, } } 注意: session.accessToken のように1段階深くなるため、既存コードの参照箇所を一括で書き換える必要があります。\n（2026/6/9追記）公開後のセキュリティ運用見直しで、AT/RTをクライアントJSから構造的に到達不能にする構成へ移行しました。session schemaにはトークンを持たせず、AT/RTはHttpOnly + 暗号化された専用Cookie（account_data）に分離して保管します。sessionは userId などの識別情報だけを持ちます。クライアントは同一OriginのBFF（Backend for Frontend）プロキシ（/api/proxy/*）を呼び出すだけです。プロキシがサーバー側でCookieからトークンを取り出し、Authorization: Bearer を付与してバックエンドへ転送します。これにより fetch('/api/auth/get-session') 経由でもトークンは取得できず、XSSや悪意ある拡張機能による持ち出しを根本的に防げます。当初は customSession でsession応答からRTを除去する方式を採りましたが、保管先そのものを分離する現構成へ発展させています。実装の詳細は better-auth 公式ドキュメント を参照してください。（追記ここまで）\n3. セッション取得・状態更新編 型定義の違いを押さえたところで、実際にセッションを取得・更新する方法の違いを確認します。\nQ. サーバーコンポーネント（SSR/RSC）でセッションを取得したい NextAuth: auth() を呼び出すだけで取得可能でした。\nbetter-auth: Cookieを参照する都合上、auth.api.getSession({ headers }) でヘッダーを明示的に渡す必要があります。\n※Edge Runtimeで動くミドルウェアでは auth オブジェクトを直接インポートできません。auth.ts が依存するライブラリ（PrismaなどのDBドライバ）がEdge環境に対応していないことが多いためです。代わりに、Cookieのみを解析する getSessionCookie や、その結果を保持する getCookieCache を使用します。ビルドエラーを回避しつつ高速な認証チェックが可能です。\nQ. ログイン直後に画面のセッション状態を最新化したい better-auth特有の注意点: カスタムエンドポイント（authClient.$fetch など）経由でログインした場合、内部のAtom（グローバルストア）は自動更新されません。ログイン成功後に useSession().refetch() を実行し、明示的にセッションを再取得してください。\n4. トークン管理・リフレッシュ編 セッションの取得方法が分かったところで、移行時に最も設計判断が求められるトークン管理について解説します。\nこのセクションの前提: better-authは「セッション管理の基盤」を提供しますが、バックエンド API と連携するトークンリフレッシュの仕組みは組み込まれていません。NextAuthでは jwt コールバックに全部入りで書けましたが、better-authでは以下を個別に設計・実装する必要があります。\n自前で作るもの 役割 対応ファイル例 カスタムプラグイン サインイン・トークン交換・リフレッシュの3エンドポイントを定義。バックエンドAPIとのブリッジ auth-plugin.ts getAccessToken() APIリクエスト前にトークンを取得する関数。キャッシュ確認 → セッション取得 → リフレッシュ判定を行う auth-token.ts customFetch Orval等のAPI自動生成ツールが使うfetchラッパー。getAccessToken() を呼んで Authorization ヘッダーを自動付与する custom-fetch.ts トークンキャッシュ + signOut ラッパー モジュールスコープの変数によるキャッシュと、キャッシュクリア付きのサインアウト関数 auth-token.ts JWT デコードユーティリティ バックエンドが発行する AT・RT の JWT から exp（有効期限）を抽出する。refreshTokenExpiresAt の取得に必須 jwt.ts ミドルウェア（ルート保護） getCookieCache でセッションを取得し、認証必須パスへの未認証アクセスをリダイレクトする middleware.ts better-authはセッション管理やプラグインシステムなどの基盤を提供しますが、上記コンポーネントの構築は開発者の責任です。\nQ. トークンをリフレッシュさせたい NextAuth: サーバーサイドの jwt コールバック内で、期限切れを検知して暗黙的に自動実行させるのが一般的でした。\nbetter-auth: サーバーサイドでのリフレッシュは行わず、クライアントサイドからの明示的な呼び出しに委譲します。\n理由: サーバーサイドには同時リクエストを制御する仕組み（Mutex）がないため、二重リフレッシュのリスクがあるからです。また、SSR中にリフレッシュトークンを消費すると Set-Cookie がブラウザに確実に反映される保証がありません。\n補足: SSR でトークンが期限切れだったら画面はどうなる？ サーバーサイドではトークン取得関数が null を返します。そのためSSRではデータなし（ローディング状態）のHTMLを返却します。ハイドレーション後、TanStack Queryがクライアント側でリフレッシュ・再取得するため、初回表示が一瞬ローディングになるだけです。\n対策:\nタイマーやポーリングは使わない。APIリクエストのたびに呼ばれる getAccessToken() 内で「残り期限が60秒未満か？」をチェックし、条件を満たした場合だけリフレッシュを実行する（オンデマンド方式） TanStack Queryによる複数クエリの同時発火でリフレッシュAPIが多重に呼ばれないよう、クライアント側でリフレッシュ用Promiseを共有するMutex制御が必須となる 補足: 「Mutex」と言ってもOSレベルの排他制御ではありません。 モジュールスコープに let refreshPromise: Promise | null を1つ持つだけのシンプルな実装です。リフレッシュ中なら後続は同じPromiseを await し、完了後 null へ戻します。\n1 2 3 1件目のリクエスト → refreshPromise を作成 2件目・3件目 → 既存の refreshPromise を await（新しい fetch は発行しない） 完了 → refreshPromise = null に戻す flowchart TD A[APIリクエスト発生] --\u0026gt; B[\u0026#34;getAccessToken()\u0026#34;] B --\u0026gt; C{残り期限 60秒以上？} C -- Yes --\u0026gt; D[キャッシュから即返却] C -- No --\u0026gt; E{refreshPromise\\nが存在する？} E -- Yes --\u0026gt; F[既存の Promise を await] E -- No --\u0026gt; G[リフレッシュ実行] G --\u0026gt; H[新トークンをキャッシュに保存] H --\u0026gt; I[新トークンで返却] F --\u0026gt; I Q. リフレッシュトークン（RT）の有効期限も監視すべき？ はい。 ATだけでなく、RTの有効期限（refreshTokenExpiresAt）もセッション・キャッシュの両方で追跡し、どちらかが期限切れ間近であればリフレッシュをトリガーします。\nRTの有効期限が短い場合（例: 5分）、ATの期限だけを監視しているとRTが先に失効してリフレッシュ自体が不可能になるリスクがあります。\n1 2 3 4 リフレッシュ判定: shouldRefreshAccess = ATが期限切れ60秒前か？ shouldRefreshRT = RTが期限切れ60秒前か？ → どちらかが true ならリフレッシュ実行 （2026/6/9追記）上記の「RT期限も監視する」方針は、§2で述べたAT/RT分離構成では不要です。AT/RTを専用Cookieに分離した後は、authClient.getSession() の戻り値にトークンが一切現れません。リフレッシュはすべてBFFプロキシがサーバー側で実行します（転送前の先制リフレッシュ + 401時の自動リトライ）。そのため、JS側でAT/RTの期限を監視する必要はありません。ログイン状態の判定は、トークンの有無ではなくセッションオブジェクト内の userId などの識別情報の存在で行います。（追記ここまで）\nQ. リフレッシュが失敗したらどうなる？ NextAuth: セッションに error: 'RefreshAccessTokenError' を格納し、呼び出し元で判断する方式でした。\nbetter-auth: グレースフルデグラデーションを採用しています。\nグレースフル・デグラデーション（Graceful Degradation）とは、障害時に全機能を停止させず、利用可能な範囲でサービスを継続する設計方針です。ここでは、RTのリフレッシュに失敗してもATが有効な間は操作を継続できるようにする戦略を指します。\nflowchart TD A[\u0026#34;リフレッシュ失敗（!res.ok）\u0026#34;] --\u0026gt; B{AT はまだ有効？} B -- Yes --\u0026gt; C[AT をそのまま返す] C --\u0026gt; D[refreshTokenExpiresAt を省略して\\nキャッシュに保存] D --\u0026gt; E[refreshFailed フラグを立てる] E --\u0026gt; F[操作を継続] B -- No --\u0026gt; G[キャッシュクリア] G --\u0026gt; H[ログインページにリダイレクト] RTのリフレッシュに失敗しても、ATがまだ有効であれば即座にサインアウトせず、ATの残り期間で操作を継続できます。\n補足: リフレッシュループの防止 バックエンドがシングルユース（使い捨て）RTを採用している場合、一度失敗したRTで再試行しても必ず失敗します。これを防ぐために2層の防御を実装しています。\nクライアント側（auth-token.ts）: モジュールスコープに refreshFailed フラグを用意し、HTTPエラー応答（=サーバーのRT拒否）時にセット。フラグが立っている間はリフレッシュを試みず、AT有効ならそのまま返却、AT期限切れなら即サインアウト。フラグは signOut() でのみリセット（clearTokenCache() ではリセットしない）。なお、ネットワークエラー（一時的障害）ではフラグをセットしない。復旧後のリフレッシュ成功を見込んでの判断 サーバー側（auth-plugin.ts）: リフレッシュ失敗時に setSessionCookie で無効RTをセッションCookieから削除。ページリロード後も古いRTによるリフレッシュ再試行を防止 Q. リフレッシュ API に authClient.$fetch を使ったら 415 エラーになるのはなぜ？ リフレッシュAPIはセッションCookieからRTを読み取るためリクエストボディは不要です。しかしPOSTメソッドである以上、空オブジェクト {} を渡すことになります。authClient.$fetch は内部的にbetter-authの betterFetch を使用しています。ボディが実質空のPOSTでは Content-Type: application/json ヘッダーを省略する場合があります。サーバー側（createAuthEndpoint）はJSONボディを期待するため、Content-Type がないと 415 Unsupported Media Type を返します。\n1 2 3 4 5 6 7 8 9 10 // NG: authClient.$fetch → Content-Type が付かず 415 になる場合がある await authClient.$fetch(\u0026#34;/refresh-token\u0026#34;, { method: \u0026#34;POST\u0026#34;, body: {} }); // OK: 生の fetch で明示的にヘッダーを付ける await fetch(\u0026#34;/api/auth/refresh-token\u0026#34;, { method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; }, body: \u0026#34;{}\u0026#34;, credentials: \u0026#34;include\u0026#34;, // Cookie 送信に必要 }); 5. ログアウト編 トークン管理の設計が整ったら、最後にログアウト処理の違いを確認します。\nQ. ログアウト処理を行いたい NextAuth: signOut() を呼ぶだけで完了します。\nbetter-auth: authClient.signOut() を直接呼ぶと、タブ単位で保持しているインメモリのトークンキャッシュが残ってしまう場合があります。必ずトークンキャッシュをクリアしてから signOut() を実行する独自のラッパー関数を作成して使用します。\n補足: 「インメモリのトークンキャッシュ」とは？ better-authが持つものではなく、自前で実装するモジュールスコープの変数です。authClient.getSession() は内部キャッシュの都合でリフレッシュ後も古いトークンを返す場合があります。そのためリフレッシュ成功時は自前のキャッシュを更新し、後続リクエストでは getSession() を呼ばず即座に返す仕組みです。タブ（ページ）ごとに独立しており、リロードで消えます。\n1 2 3 4 export const signOut = async (): Promise\u0026lt;void\u0026gt; =\u0026gt; { clearTokenCache(); // メモリキャッシュ破棄 await authClient.signOut(); // Cookie 削除 }; 6. 【おまけ】 import 置き換え早見表 よく使うメソッドのインポート元は以下のように変わります。\n用途 NextAuth (v5) better-auth Hook import { useSession } from 'next-auth/react' import { useSession } from '@/lib/auth-client' ログイン import { signIn } from 'next-auth/react' authClient.$fetch('/sign-in/credentials', ...) ログアウト import { signOut } from 'next-auth/react' import { signOut } from '@/lib/auth-token'（※ラッパーを使用） サーバー取得 import { auth } from '@/lib/auth' → auth() import { auth } from '@/lib/auth' → auth.api.getSession({ headers }) SessionProvider import { SessionProvider } from 'next-auth/react' 不要（削除） API Route import { handlers } from '@/lib/auth' import { toNextJsHandler } from 'better-auth/next-js' 7. 参考リンク（better-auth 公式ドキュメント） 本チートシートで触れた機能に対応する公式ドキュメントへのリンクです。\nトピック URL 本記事の関連セクション Next.js integration https://www.better-auth.com/docs/integrations/next 1. API Route、3. SSR取得、ミドルウェア Session Management（Cookie Cache / JWT Strategy） https://www.better-auth.com/docs/concepts/session-management 1. Provider不要の背景、4. トークン管理全般 Custom Plugin の作り方 https://www.better-auth.com/docs/guides/your-first-plugin 4. カスタムプラグイン（sign-in / refresh エンドポイント） 型推論（inferAdditionalFields） https://www.better-auth.com/docs/concepts/typescript 2. スキーマ推論 まとめ better-authへの移行によって、型定義の二重管理から解放され、不要なProviderを削減できます。\n一方で、以下のようにクライアントサイドでの状態・通信管理をより明示的に設計する必要がある点には注意が必要です。\n注意点 概要 自前実装の範囲が広い プラグイン・トークン管理・customFetch・JWT デコード・ミドルウェアなど、認証フロー全体を自分で組み立てる必要がある リフレッシュの並行処理制御（Mutex） TanStack Queryの同時クエリ発火で多重リフレッシュが発生する ログイン後の refetch() カスタムエンドポイント経由ではAtomが自動更新されない 空POSTの415エラー回避 authClient.$fetch ではなく生の fetch + Content-Type ヘッダーを使用 セッション構造の変化 フラット → ネスト（session.accessToken）への参照書き換え AT・RT 両方の期限監視 RTが先に失効するとリフレッシュ自体が不可能になる リフレッシュ失敗のフォールバック ATが有効なら即サインアウトせずグレースフルデグラデーション ","permalink":"https://andfactory.co.jp/techblog/posts/nextauth-to-better-auth-migration","summary":"\u003cp\u003e\u003cstrong\u003e（2026/6/9追記）\u003c/strong\u003e 本記事の公開後にセキュリティ運用の見直しを行い、§2と §4にそれぞれ追記を加えています。最新の推奨構成は各セクションの追記をご確認ください。（追記ここまで）\u003c/p\u003e\n\u003ch2 id=\"better-auth-が-authjs-を公式に引き継いだ発表\"\u003eBetter Auth が Auth.js を公式に引き継いだ発表\u003c/h2\u003e\n\u003cp\u003e2025年9月付近で、Auth.js（旧NextAuth.js、以下NextAuth）の公式発表がありました。\u003cstrong\u003eBetter Auth（npmパッケージ名: better-auth、以下better-auth）チームに引き継がれる\u003c/strong\u003eという内容です。\u003c/p\u003e\n\u003cp\u003e発表された概要は以下のとおりです。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eAuth.jsのセキュリティ修正やクリティカルな対応は継続される\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e新機能・今後の進化は Better Auth 側に集約\u003c/strong\u003eされる\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eNextAuthの開発メンバーが関わる形でBetter Authへ収束していく方針です。\u003c/p\u003e\n\u003cp\u003e既存のプロジェクトであればすぐさまbetter-authに切り替える必要はありませんが、新規のプロジェクトであればbetter-authも有力な選択肢です。\u003c/p\u003e\n\u003cp\u003e今回、新規プロジェクトの立ち上げにあたり、この公式発表を受けてbetter-authを採用しました。NextAuth v5では \u003ccode\u003ejwt\u003c/code\u003e コールバックにリフレッシュ処理を集約していましたが、ロジックの肥大化により保守が困難になっていました。better-authのプラグインベースの設計に魅力を感じたことも決め手の1つです。\u003c/p\u003e\n\u003cp\u003eこのドキュメントは、\u003cstrong\u003eNext.js App Router\u003c/strong\u003e と \u003cstrong\u003eTanStack Query\u003c/strong\u003e を採用したプロジェクトを対象としています。バックエンドAPIが「\u003cstrong\u003eJWT ＋ リフレッシュトークン\u003c/strong\u003e」を発行するステートレスな構成において、NextAuth (v5) からbetter-authへ移行する際のノウハウを逆引き形式でまとめました。\u003c/p\u003e\n\u003cp\u003eこれからbetter-authの導入や移行を検討しているエンジニアの方は、ぜひ参考にしてください。\u003c/p\u003e\n\u003ch3 id=\"動作確認環境\"\u003e動作確認環境\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eライブラリ\u003c/th\u003e\n          \u003cth\u003eバージョン\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eNext.js\u003c/td\u003e\n          \u003ctd\u003e16.0.7\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eReact\u003c/td\u003e\n          \u003ctd\u003e19.x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003ebetter-auth\u003c/td\u003e\n          \u003ctd\u003e1.4.18\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e@tanstack/react-query\u003c/td\u003e\n          \u003ctd\u003e5.75.4\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eNode.js\u003c/td\u003e\n          \u003ctd\u003e24.11.1\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-初期設定ルーティング編\"\u003e1. 初期設定・ルーティング編\u003c/h2\u003e\n\u003ch3 id=\"q-フロントエンドでセッション情報を共有したいproviderの配置\"\u003eQ. フロントエンドでセッション情報を共有したい（Providerの配置）\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eNextAuth:\u003c/strong\u003e ルートレイアウトなどに \u003ccode\u003e\u0026lt;SessionProvider\u0026gt;\u003c/code\u003e を配置してReact Contextでセッションを共有する必要がありました。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003ebetter-auth:\u003c/strong\u003e Providerは一切不要（削除）です。better-authはCookieベースで動作し、内部的に \u003ca href=\"https://github.com/nanostores/nanostores\"\u003enanostores\u003c/a\u003e のAtomを利用して各コンポーネントに状態を共有します。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e補足: なぜ Provider なしで動くのか？\u003c/strong\u003e\nNextAuthはReact Context（Provider → Consumer）でセッションを配信していました。better-authはReactの外にあるグローバルストア（nanostoresのAtom）にセッション状態を保持し、\u003ccode\u003euseSession()\u003c/code\u003e はそのAtomをsubscribeするだけです。Reactツリーに依存しないためProviderが不要になります。初回マウント時にCookieから \u003ccode\u003e/api/auth/get-session\u003c/code\u003e をfetchしてAtomを初期化し、以降はAtomの値を返します。\u003c/p\u003e","title":"【Next.js App Router】NextAuth v5 から better-auth へ！逆引き移行チートシート"},{"content":"はじめに and factory Tech Blog を開設しました。\nこのブログでは、and factory のエンジニアが日々の開発で得た知見や技術的な取り組みを発信していきます。\n発信する内容 技術選定の背景と意思決定 開発プロセスの改善 新しい技術の検証・導入事例 チーム開発のノウハウ 今後の記事をお楽しみに！\n","permalink":"https://andfactory.co.jp/techblog/posts/hello-world","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eand factory Tech Blog を開設しました。\u003c/p\u003e\n\u003cp\u003eこのブログでは、and factory のエンジニアが日々の開発で得た知見や技術的な取り組みを発信していきます。\u003c/p\u003e\n\u003ch2 id=\"発信する内容\"\u003e発信する内容\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e技術選定の背景と意思決定\u003c/li\u003e\n\u003cli\u003e開発プロセスの改善\u003c/li\u003e\n\u003cli\u003e新しい技術の検証・導入事例\u003c/li\u003e\n\u003cli\u003eチーム開発のノウハウ\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e今後の記事をお楽しみに！\u003c/p\u003e","title":"and factory Tech Blog を開設しました"}]