この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 11 回(全 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
対象読者は、層に分けた設計で「実際にリクエストがどう流れるのか」を通しで見たことがないエンジニアです。第 6〜10 回で見た層を、1 本のリクエストで縦断します。
- URL のクエリ 1 つが画面に出るまでに 5 つの層 を通ります
- 同じキーワードを 検証済みの値と生の値の 2 通り で持つ、という判断が読みどころです
- エラーは例外として投げっぱなしにせず、画面に出す値 として扱われます
追跡するもの
こういう URL でアクセスしたとき、何が起きるかを追います。
/ja?q=react&page=2&sort=stars&per_page=50
全体の流れはこうです。
最後の 1 本(lookupGemIndexes)だけ順番が後ろにずれています。検索が終わってからでないと始められない からです。どのリポジトリが返ってくるか分からないうちは、照会するキーが手元にありません。だから await statePromise の後に 1 本だけ追加で待ちます(この記事の後半で扱います)。
① URL からパラメータを取り出す
const rawSearchParams = await searchParams
const { keyword, page, sort, perPage } = parseSearchParams(rawSearchParams)
// 🔴 検索の実行にはキーワードの生値を使う(parseSearchParams は不正値を '' へ倒すため、
// そのまま渡すと拒否理由が「未入力」にすり替わる)。入力欄の表示も生値にして、
// エラーを見たユーザーが自分の入力を直せるようにする。
const rawKeyword = rawKeywordOf(rawSearchParams)
いきなり本連載でいちばん細かい判断が出てきます。同じキーワードを 2 通りで持っています。
| 変数 | 中身 | 使い道 |
|---|---|---|
keyword |
正規化済み(不正なら空文字) | 画面の表示用 |
rawKeyword |
URL に書かれたそのまま | 検索の実行用 |
なぜ分けるのか。コメントが答えです。
ユーザーが react is:private と入力したとします(第 7 回で見た、弾くべき修飾子構文です)。
- 正規化済みの値を検索に使うと → 空文字になる → 「まだ何も検索していません」と表示される
- 生の値を検索に使うと → 検証で弾かれる → 「検索キーワードを確認してください」と表示される
前者はユーザーに「拒否された」ことが伝わりません。 入力欄も空になるので、何を書いたかも分からなくなります。
「不正な値は既定値に倒す」(第 7 回の tryParse)は良い方針ですが、倒した結果がユーザーに嘘をつくなら、そこでは倒してはいけない。この使い分けが実装に落ちています。
② パラメータ名を 1 箇所で管理する
export const SEARCH_PARAM_KEYS = {
keyword: 'q',
page: 'page',
sort: 'sort',
perPage: 'per_page',
/** Gem 一覧へ、検索結果でバッジが付いた `fullName` を同伴させるためのキー(後述) */
badged: 'badged',
} as const
export function parseSearchParams(input: RawSearchParams): ParsedSearchParams {
const rawKeyword = firstValue(input[SEARCH_PARAM_KEYS.keyword])
// ...
const keyword = trySearchKeyword(rawKeyword) ?? ''
const page = tryPageNumber(rawPage)
const sort = trySortOrder(rawSort)
const perPage = tryPerPage(rawPerPage)
return { keyword, page, sort, perPage }
}
'q' という文字列がコード中に散らばると、必ずどこかで typo します。定数にして、フォームの name 属性からも E2E テストからも同じ定数を参照する形になっています(第 5 回・第 16 回)。
firstValue は「同じパラメータが 2 回書かれたとき最初のものを使う」処理です。?q=a&q=b のような URL を手で書ける以上、配列で来る場合を考えておく必要があります。
5 つ目の badged は、この規律が 後から試された 跡です。これは検索の 4 条件そのものではなく、別の画面へ値を引き渡すためのキーなので、専用の定数として独立させることもできました。それでもこのオブジェクトへ寄せられ、JSDoc に「独立定数にはせず本オブジェクトへ寄せる(URL パラメータ名の正本を 1 箇所に保つ)」と理由が書かれています。規律は、守られている間より、例外を作れる場面で破らなかったときのほうが確かめられます。
③ 境界で値オブジェクトへ変換する
try {
// 境界(URL)で値オブジェクトへ変換する。
// 🔴 不正値を黙って握りつぶさない(trySearchKeyword を使わない)。修飾子入りキーワード
// (`react is:private` 等)は DomainValidationError になるので、下の catch で
// 「検索キーワードを確認してください」相当として画面に出す。null へ倒すと
// 未入力と同じ idle 表示になり、拒否された事実がユーザーに伝わらない。
const keyword = searchKeyword(rawKeyword)
const sort = trySortOrder(rawSort)
await enforceSearchRateLimit(await headers())
const result = await searchRepositoriesUseCase(accessToken)({
keyword,
page: tryPageNumber(rawPage),
sort,
perPage: tryPerPage(rawPerPage),
})
return { status: 'ok', result }
} catch (error) {
// ...
}
ここで searchKeyword(例外を投げる方)と trySortOrder(既定値に倒す方)が混在している のが要点です(第 7 回の二段構え)。
- キーワード: 不正なら例外 → ユーザーに伝える必要があるから
-
ソート順: 不正なら既定値 →
?sort=xxxと手で書き換えた人に、関連順で見せれば十分だから
同じ URL のパラメータでも、扱いが違う わけです。
enforceSearchRateLimit(await headers()) はレート制限(第 18 回)。headers() を呼んだ時点でこのページは動的レンダリングになります。第 2 回のビルド出力で検索画面が ƒ(動的)だったのはこれが理由です。
④ エラーを「投げる」のではなく「返す」
catch の中を見ると、エラーを再送出せず 値として返しています。
return { status: 'error', kind: 'validation' }
戻り値の型はこうなっています。
type SearchState =
| { status: 'idle' } // まだ検索していない
| { status: 'ok'; result: SearchResult } // 成功
| { status: 'error'; kind: ErrorKind } // 失敗(種類つき)
エラーは例外ではなく、画面の状態の 1 つ として扱われます。だから error.tsx が不要になり(第 4 回)、「検索は失敗したがヘッダーとフォームは正常に出る」という部分的な表示ができます。
⑤ Promise を 1 本だけ作って配る
// 🔴 Promise は 1 本だけ作り、ライブリージョン側と結果本体側の両方へ渡す(検索は 1 回)。
// どちらの await より前に reject しても unhandled rejection にしないため、no-op を 1 つ張る。
const statePromise = runSearch(rawKeyword, page, sort, perPage, accessToken)
void statePromise.catch(() => undefined)
検索結果は 2 箇所で使われます。「結果一覧」と「読み上げソフト向けの件数アナウンス」(第 12 回)です。
素直に書くと API を 2 回叩きます。そこで Promise を 1 本だけ作り、両方に配ります。
void statePromise.catch(() => undefined) は保険です。Promise が誰にも await される前に失敗すると Node.js が unhandled rejection として警告を出すので、何もしない catch を 1 つ張っておきます。
⑥ Suspense に key を付ける
/**
* 🔴 `<Suspense>` に検索条件由来の `key` を与える(US-22)。React は transition 中の
* **既存の** 境界へ fallback を再表示しないため、key が無いとページング・ソート変更・
* 表示件数変更(next/link のクライアント遷移)で「読み込み中」が出ず、古い一覧が
* 残ったままになる。
*/
const suspenseKey = buildSearchUrl(basePath, { ...searchState, keyword: rawKeyword })
エラーが出ない不具合 の 3 つ目です。
2 ページ目へのリンクを踏むと、React は「同じ Suspense 境界の中身が変わった」と解釈して、fallback(読み込み中)を出さずに古い一覧を表示したまま 待ちます。ユーザーには「クリックしたのに何も起きない」ように見えます。
key に検索条件を含めると、条件が変わるたびに「別の境界」とみなされ、fallback が出ます。
⑦ 一部の失敗で全体を巻き込まない
// 🔴 **二重防御**: 候補プールの読み込みは StaticGemDigest 側で例外を投げない設計だが、
// ここでも `.catch(() => null)` を張って「ダイジェストの失敗がトップページ全体を
// 500 にする」経路を塞ぐ(app/ 配下に error.tsx は無く、失敗すれば既存の検索機能まで
// 巻き添えになる)。
const dailyDigest = hasKeyword
? null
: await getDailyDigestUseCase()({ seed: dateSeed, limit: DAILY_DIGEST_LIMIT }).catch(() => null)
日次ダイジェストは「あると嬉しい」機能で、検索機能の本体ではありません。その読み込み失敗で検索まで使えなくなるのは割に合いません。
呼び出し先が例外を投げない設計になっていても、ここでも .catch() を張る。「相手が約束を守る前提」に頼らない多層防御です。
もう 1 本、同じ形で通っている
ここまで追ったのは /{locale} の 1 本ですが、このアプリには app/[locale]/gems/page.tsx という 同じ形の経路がもう 1 本 あります。URL のクエリを受け、src/ui/url/ の定数で名前を解き、レート制限を通し、src/usecases/ を呼び、ポートの向こうから返った結果を描く。層の並びも、通す順番も同じです。
冒頭の図で後ろにずれていた lookupGemIndexes も、この 2 本目とつながっています。検索結果が出たあとに 1 回だけ呼んで、返ってきた fullName にバッジを付ける。その照会先が、2 本目の読んでいる候補プールと同じものです。
2 本目を読む価値は「同じだった」ところではなく、同じ規律から違う実装が出てきた ところにあります。差分を 2 つ見ます。
1. 順番が同じなのではなく、規律が同じ
1 本目は searchKeyword(rawKeyword) で値オブジェクトへ変換し(不正なら例外)、そのあと enforceSearchRateLimit を通してユースケースを呼びました。
2 本目には、この 値オブジェクトへの変換がありません。代わりに rawQuery.trim().length === 0 で早期 return し、そのあと enforceGemListRateLimit を通してユースケースを呼びます。
一見すると「1 本目のやり方を守っていない」ように見えます。しかし、間引きを置いた位置の JSDoc にはこう書かれています。
位置は「検索語なしの早期 return より後・候補プールの読み込みより前」。検索経路が「値オブジェクトへの変換が通った入力だけを対象にする」(不正入力で枠を消費しない・
app/api/search/route.test.ts)のと同じ規律で、gems.queryRequiredに倒れる入力では枠を消費しない。逆に消費の判定は重い処理(searchGemsUseCase())より前に済ませ、間引きの目的(負荷の抑制)を果たす。
守っているのは 「400 に倒れる入力でレート制限の枠を消費しない」という規律 であって、searchKeyword() を呼ぶという手順ではありません。2 本目には守るべき不変条件が別にある(照合は英数字の単語境界一致なので、記号や日本語は変換しなくても安全に落ちる)ので、同じ規律から別の実装が導かれました。
手順をコピーすると、前提が違う 2 本目で意味を失います。規律をコピーすれば、実装は変わっても目的は残ります。
ページ番号にも同じことが起きています。2 本目は tryPageNumber を使いません。PageNumber の上限 50 は、GitHub 検索 API が 1,000 件までしか返せない ことから決まった値です。候補プールは GitHub API を通らない静的データなので、その上限は当てはまりません。実データには com 8,913 件・github 8,156 件と 1,000 件を超えるトークンが実在するので、揃えると 50 ページ目より後ろが到達不能になります。ポートの JSDoc には「規約違反ではなく 意図的な非採用 である」と書き残されています。
値オブジェクトは「どこでも使うほど良いもの」ではありません。その値オブジェクトが守っている不変条件が、その場所でも成り立つか を毎回確かめる必要があります。
2. 2 つの画面で判定が違うとき、URL に載せて連れて行く
もう 1 つの差分は、この 2 本の画面が 同じ検索語に対して違う判定をする ことです。検索結果のバッジは「候補プールに載っているか」の所属照会で付きます。一方 Gem 一覧は、repo 名・パッケージ名の全語 AND・単語境界一致で絞り込みます。
だから q=next.js と打つと、検索結果にはバッジ付きが何件も並ぶのに、一覧に移ると 1 件に落ちます。利用者から見れば、同じ検索語なのに件数が減った状態です。
解き方は、判定をどちらかに寄せるのではなく、1 本目で既に分かっている答えを URL に載せて 2 本目へ渡す でした。GitHub API を追加で叩く必要はありません。答えはもう手元にあるからです。
const rawBadged = rawBadgedOf(rawSearchParams)
/**
* 🔴 ページ送り・言語切替・再試行リンクへ埋め込む値は **正規化後**(`normalizeIncludeFullNames`)
* を使う。絞り込みに使われる値(`searchGemsUseCase` へ渡す `rawBadged`)と生値のまま食い違うと、
* 不正形式・21 件目以降・極端に長い値がそのまま URL に複製され続ける。`buildSearchUrl` は
* 空文字の値を省略する契約なので、正規化結果が空配列(`join(',')` が `''`)なら `badged`
* パラメータ自体が付かない。
*/
const badgedExtraParams = { badged: normalizeIncludeFullNames(rawBadged).join(',') }
badged の中身は owner/repo をカンマ区切りにしたものです。②で見た SEARCH_PARAM_KEYS に 5 つ目として足されたキーが、ここで使われています。
なお、ここでも「ユースケースへ渡すのは生値・リンクへ埋めるのは正規化後」と値が 2 つに分かれています。①で見た「同じ値を 2 通りで持つ」判断が、別の理由(URL が自分自身を複製するので、不正な値を運び続けさせない)から繰り返されています。
まとめ
- 同じ値を「検証済み」と「生」の 2 通りで持つ 判断がある。エラー表示のために生値が要る
- パラメータ名は定数にして、フォーム・テストからも同じ定数を参照する
- 例外にするか既定値に倒すかは、パラメータごとに違ってよい
- エラーは投げっぱなしにせず、画面の状態として返す
- Promise は 1 本だけ作って配る
- Suspense に key を付けないと、ページ遷移で「読み込み中」が出ない(エラーは出ない)
- 同じ形の経路が 2 本目にもある。コピーされているのは手順ではなく規律(不正入力でレート制限の枠を消費しない)で、前提が違えば実装は変わる
シリーズの前後の記事
- ⬅️ 前の記事: 第 10 回 依存規則を440行のPythonで機械検査する
- ➡️ 次の記事: 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。