この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 3 回(全 19 回)です。
実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。
全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。
シリーズ全体の目次
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする(この記事)
- 第 4 回 App Router の地図。フォルダがそのまま URL になる
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
- 第 13 回 どの層を何でテストするかを表で決めておく(公開予定)
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する(公開予定)
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する(公開予定)
- 第 16 回 外部APIに依存しないE2Eの組み方(公開予定)
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体(公開予定)
- 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する(公開予定)
- 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方(公開予定)
TL;DR
- この回はコードが出てこない唯一の回です。早くコードが読みたい人は第 4 回から始めても構いません
- 要件に ID を振る と、コードから要件を逆引きできるようになります
- ADR(設計判断の記録)の価値は「決めたこと」より「却下したこと」にあります
対象読者は、設計判断をどう残せばいいか迷っているエンジニアです。要件に ID を振る・ADR を書くという運用が、読む側にとって何の役に立つのかを実例で見ます。
解こうとしている問題
GitHub でライブラリを探すとき、私たちはたいてい star 数の多い順に見ます。ところが star は 注目度 の指標であって、実際に使われているか の指標ではありません。
gem-hunter の要件定義書は、この前提をひっくり返すところから始まります。
star は注目度の指標であって利用実績ではない。約 600 万個の偽 star が観測されており、star 順に並べるほど「実際に使われているが知られていないもの」は見えなくなる。
実例として、
debug_inspectorは 25 star でありながら 111,000 以上の OSS プロジェクトから依存されている。
「Gem」の定義
面白いのは、この問題を どう定義したか です。
Gem とは、「実際に使われている度合い(被依存数)」に対して「注目度(star 数)」が不釣り合いに小さいリポジトリである。
判定は 2 つの軸を 役割分担 させて行います。
| 軸 | 指標 | 役割 |
|---|---|---|
| 過小評価度 | 被依存数の順位 − star の順位 | 並び順(ランキング) |
| 健全性 | OpenSSF の既存スコア(自作しない) | 足切り(フィルタ) |
そして、要件定義書には赤字でこう書かれています。
2 軸を 1 つのスコアに合算しない。 合算すると「健全だが有名」なリポジトリが上位に戻り、否定したはずの star 追随ランキングに退化する。
これは単なるアルゴリズムの選択ではなく、プロダクトの存在理由に直結する制約 です。合算した瞬間にこのアプリは「GitHub 検索の劣化版」になります。だから合算しない、と要件の側で先に釘を刺してある。
こういう「やらないことの明文化」は、コードを読むときの強力な手がかりになります。実装を見て「なぜここで 2 つの値を別々に扱っているのだろう」と思ったとき、答えは実装ではなく要件の側にあるからです。
要件に ID が振ってある
gem-hunter では、すべての要件に ID が振られています。
| 接頭辞 | 意味 |
|---|---|
FR-n |
与件(外部から与えられた仕様)由来の機能要件 |
AR-n |
与件に 上乗せ した機能要件 |
NFR-n |
非機能要件(性能・セキュリティ・アクセシビリティなど) |
AC-n |
受け入れ基準 |
ADR-n |
設計判断の記録 |
そしてルールがあります。
一度振った ID は変更・再利用しない(欠番が出ても詰めない)。派生ドキュメントからの参照が壊れるため。
このおかげで、コードの中から要件を参照できます。実際、リポジトリのあちこちにこういうコメントが出てきます。
-
NFR-24(テストは外部ネットワークに出ない)→ MSW の設定にこの ID が出てくる -
NFR-7(自リクエストの間引き)→ レート制限の実装にこの ID が出てくる -
AC-12(private リポジトリを出さない)→ E2E テストのファイル名がac-12-private.spec.ts -
ARCH-5(秘密情報を読んでよい場所の限定)→ 依存規則の検査スクリプトにこの ID が出てくる
読む側のメリットは明確です。あるコードが「なぜ存在するのか」を、コードを追わずに逆引きできる。
試しに手元でこう叩いてみてください。1 つの要件 ID がコードのどこに現れるかが分かります。
grep -rn "NFR-24" src/ e2e/
ADR — 設計判断を、却下案ごと残す
ADR(Architecture Decision Record)は「なぜその技術・その構成を選んだか」を 1 判断 1 ファイルで残す形式です。gem-hunter には 15 本あります。
| # | 決めたこと |
|---|---|
| 0001 | UI スタックに Tailwind CSS v4 + shadcn/ui を採用する |
| 0002 | インフラを Cloudflare Workers に確定する |
| 0003 | サーバー側の GitHub 認証を GitHub App の installation token にする |
| 0004 | リリースを trunk-based にし、常設の dev 環境を持たない |
| 0005 | Cache Port を YAGNI の意図的な例外として維持する |
| 0006 | Next.js 16 + App Router を採用する |
| 0007 | DB を持たず、状態をクライアント側へ寄せる |
| 0008 | 無限スクロールではなくページネーションにする |
| 0009 | Hidden Gem を「被依存数に対する star の残差」と定義する |
| 0010 | 複数トークンのローテーションを採用しない |
| 0011 | i18n を next-intl 不採用の自前実装で行う |
| 0012 | 任意の GitHub OAuth ログインを上乗せする |
| 0013 | 第三者へ公開する際の GitHub 利用規約上の立場を確定する |
| 0014 | キーワード非依存の発見面を日次ダイジェストとして実装する |
| 0015 | AI 生成ビジュアルアセットを透過 WebP のまま配信する |
ADR の価値は「決めたこと」より「却下したこと」にあります。たとえば ADR 0004(trunk-based)には、検討して却下した案が理由つきで並んでいます。
- 常設の dev 環境を置く → 却下(確認する人間も実トラフィックもいないので、PR 時点で通した CI と同じ検証をもう一度やるだけの環境になる)
-
versions+ 固定のプレビュー alias → 却下(シークレットが版の連鎖全体に継承されてしまい、dev 専用のシークレットを分離できない) - 2 段マージの自動昇格 → 却下
「dev 環境を置かない」という結論だけ見ると乱暴に見えます。しかし却下理由まで読むと、この規模・この体制では という条件付きの判断であることが分かります。そして「将来どうなったら再検討するか」の条件まで書いてあります。
ADR は「正しい答え」の記録ではなく、「その時点の情報でこう判断した」の記録です。だから後から間違いだと分かっても消しません。状態を「却下」「置き換え済み」に変えて残します。
ソースコードの地図
src/ の下は 6 つに分かれています。これが第 6〜10 回の主題 です。
src/
├── domain/ 「このアプリ固有の概念」。他のどこにも依存しない
│ ├── model/ 値オブジェクト(検索キーワード、ページ番号、ロケール、Gem…)
│ ├── ports/ 外部とやり取りするための「約束」(インターフェース)
│ └── errors.ts ドメインのエラー
├── usecases/ 「検索する」「詳細を見る」「Gem を一覧する」といった操作の段取り
├── infrastructure/ 外の世界と話す実装
│ ├── github/ GitHub API に触ってよい唯一の場所
│ └── platform/ Cloudflare 固有の機能に触ってよい唯一の場所
├── composition/ 上の 3 つを組み立てて配線する場所(`new` を書いてよい唯一の場所)
├── ui/ 画面部品
└── shared/ どこからでも使える小物(i18n の辞書など)
依存の向きはこうです。
app/(Next.js のルート)
↓
composition/(配線)
↓ ↘
usecases/ infrastructure/
↓ ↙
domain/(何にも依存しない中心)
矢印は「A が B を import してよい」という向きです。逆向きの import は機械的に禁止 されていて、破ると検査スクリプトが落ちます(第 10 回で実際に落としてみます)。
これから出てくる設計用語(顔見せ)
第 6 回以降で本格的に出てくる用語を、名前だけ先に見ておきます。ここで理解する必要はありません。
| 用語 | 一言でいうと | 詳しくは |
|---|---|---|
| 値オブジェクト | 「ただの文字列」ではなく「検索キーワードとして正しいと分かっている文字列」を型で表したもの | 第 7 回 |
| ブランド型 | TypeScript で「見た目は string だが別物」を作るテクニック |
第 7 回 |
| スマートコンストラクタ | 検証を通った値だけを作れるようにする関数 | 第 7 回 |
| ポート | 「外部とこうやり取りする」という約束だけを書いたインターフェース | 第 8 回 |
| 依存性注入(DI) | 必要な部品を自分で作らず、外から渡してもらうこと | 第 8 回 |
| ACL(腐敗防止層) | 外部サービスの語彙を、自分たちの語彙へ翻訳する境界 | 第 9 回 |
| composition root | 部品を組み立てる場所を 1 箇所に集めたもの | 第 10 回 |
まとめ
- 「やらないこと」(2 軸を合算しない)が要件の側で明文化されていると、実装の意図が読める
- 要件 ID を振ると、コードから要件を逆引きできる
- ADR は「決めたこと」だけでなく「却下したこと」と「再検討の条件」を残す
-
src/は 5 つの層+共有小物。依存の向きは機械的に検査されている
シリーズの前後の記事
- ⬅️ 前の記事: 第 2 回 clone からテストが緑になるまで
- ➡️ 次の記事: 第 4 回 App Router の地図。フォルダがそのまま URL になる
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。