この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 9 回(全 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
対象読者は、外部 API のレスポンスをそのままアプリ内で使い回して困った経験があるエンジニアです。ACL(腐敗防止層)が具体的に何をする層なのかを実例で見ます。
- 外部 API のフィールド名を アプリの中へ持ち込まない(
subscribers_count→watcherCount) - zod でスキーマを検証し、失敗をドメインのエラーへ翻訳 する
- キャッシュは 契約を変えずに後から包む(デコレータ)
API のレスポンスをそのまま使うと何が起きるか
GitHub API はこんな JSON を返します。
{
"full_name": "facebook/react",
"stargazers_count": 235000,
"subscribers_count": 6600,
"pushed_at": "2026-08-20T10:00:00Z"
}
これをそのままアプリ全体で使うと、こうなります。
- 画面のコードに
stargazers_countという GitHub の語彙が出てくる -
subscribers_countが「ウォッチしている人数」だと コードを読んでも分からない(GitHub API の癖です) - データ源を増やすとき、全部のファイルを直すことになる
外部サービスの語彙が、アプリの隅々まで侵食していく。 これを防ぐ境界が ACL(腐敗防止層)です。
使うフィールドだけ宣言する
/**
* GitHub API のレスポンススキーマ(NFR-19)。
* 🔴 アプリが実際に使うフィールドだけを宣言する(全項目を写さない)。
*/
export const repositoryDto = z.object({
id: z.number(),
name: z.string(),
full_name: z.string(),
html_url: httpsUrl,
description: z.string().nullable(),
language: z.string().nullable(),
stargazers_count: z.number(),
updated_at: z.string(),
pushed_at: z.string().nullable(),
private: z.boolean(),
// ...
})
GitHub のリポジトリ JSON は 100 以上のフィールドを持ちますが、宣言してあるのは使うものだけ です。
理由は 2 つあります。
- 使わないフィールドの型が変わってもアプリは壊れない
- 何を使っているかがこのファイルを見れば分かる
html_url: httpsUrl の httpsUrl は独自の検証で、https: 以外のスキームを弾きます。javascript: で始まる URL がそのままリンクになると、クリックでスクリプトが動きます。「外から来た URL は素通しにしない」という防御です。
検証の失敗を、上位層の言葉へ翻訳する
/**
* 外部データを検証したうえでドメインモデルへ変換する(ACL)。
* 🔴 スキーマ不一致は外へ漏らさずドメインエラーへ翻訳する(上位層は zod を知らない)。
*/
export function toSearchResult(raw: unknown): SearchResult {
const parsed = searchRepositoriesDto.safeParse(raw)
if (!parsed.success) {
throw new UpstreamError('GitHub API のレスポンスが想定と異なります', { cause: parsed.error })
}
const dto = parsed.data
return {
totalCount: dto.total_count,
incompleteResults: dto.incomplete_results ?? false,
items: dto.items
.filter((item) => !item.private)
.map((item): RepositorySummary => ({
id: item.id,
name: item.name,
fullName: item.full_name,
owner: { login: item.owner.login, avatarUrl: item.owner.avatar_url },
description: item.description,
primaryLanguage: item.language,
stars: item.stargazers_count,
lastPushedAt: lastPushedAtOf(item.pushed_at, item.updated_at),
topics: item.topics ?? [],
htmlUrl: item.html_url,
})),
}
}
読みどころが 4 つあります。
1. zod のエラーを外に出さない
safeParse が失敗したら、zod の ZodError をそのまま投げるのではなく UpstreamError(ドメインのエラー)に包み替えています。
もし zod のエラーがそのまま上へ流れたら、画面のコードが zod のエラー形式を知ることになります。そうなると zod を別のライブラリに替えられません。
2. 語彙を変換する
fullName: item.full_name, // full_name → fullName
stars: item.stargazers_count, // stargazers_count → stars
lastPushedAt: ... // pushed_at → lastPushedAt
スネークケースからキャメルケースへ、という機械的な変換ではありません。意味を持たせ直しています。
3. 非公開リポジトリを 2 段で弾く
.filter((item) => !item.private)
第 7 回で、検索キーワードから修飾子構文を弾く実装を見ました。ここではさらに、API から返ってきた結果からも private: true のものを取り除いています。
同じ要件を 2 箇所で守る 多層防御 です。片方が破られても、もう片方が残ります。実際、検索クエリ側の防御はこうです。
/**
* 🔴 検索クエリへ必ず付与する公開限定の修飾子。
* GitHub App の installation token(Bearer)で認証すると、そのトークンから見える private
* リポジトリまで `GET /search/repositories` の可視範囲に入ってしまう。本プロダクトの仕様は
* 「GitHub 公開リポジトリの検索」(prd.md L171)なので、検索の時点で公開に閉じる。
*/
const PUBLIC_ONLY_QUALIFIER = 'is:public'
is:public という GitHub 固有の語彙 が、ちゃんと infrastructure 層の中だけに閉じています。domain にはこの文字列は出てきません。
さらに、この修飾子は クエリの先頭に置く ことがテストで固定されています。末尾に置くと、キーワード側の未知の構文に吸収される可能性があるためです。「位置そのものが防御の一部」 という指摘がテストコードのコメントに残っています(第 15 回)。
4. 欠けている値の埋め方
lastPushedAt: lastPushedAtOf(item.pushed_at, item.updated_at),
pushed_at は null になることがあります(空のリポジトリなど)。そのときは updated_at で代替する、という判断を専用の関数に切り出しています。
「null が来たときにどうするか」は業務判断です。 変換の途中にインラインで書くと見落とされるので、名前を付けて外に出す。
キャッシュは「後から包む」
キャッシュ機能は、GitHub API を叩くクラスの中には 入っていません。別のクラスが外から包んでいます。
/**
* `RepositoryQueryPort` をキャッシュ付きで包むデコレータ。
*
* GitHub 固有の知識は持たない(`RepositoryQueryPort` と `CachePort` にしか依存しない)。
* HIT/MISS の伝達は `onCacheStatus` コールバックで行い、`CachePort` / `RepositoryQueryPort`
* のどちらの契約も変更しない。
*/
export class CachingRepositoryQuery implements RepositoryQueryPort {
private readonly inFlightSearch = new Map<CacheKey, Promise<SearchResult>>()
private readonly inFlightDetail = new Map<CacheKey, Promise<RepositoryDetail | null>>()
private readonly inFlightReadme = new Map<CacheKey, Promise<string | null>>()
constructor(
private readonly deps: {
inner: RepositoryQueryPort
cache: CachePort
ttlSeconds: { search: number; detail: number }
onCacheStatus?: (status: 'HIT' | 'MISS') => void
},
) {}
RepositoryQueryPort を実装しながら、RepositoryQueryPort(inner)を内側に持っています。 これがデコレータパターンです。
使う側から見ると、キャッシュ付きかどうかは区別できません。同じインターフェースだからです。だから 組み合わせを後から変えられます(キャッシュを外す、ログを足す、など)。
同じリクエストが同時に来たら
冒頭の 3 つの inFlight* は single-flight のための仕組みです。
同じキーワードで 10 人が同時に検索したとき、素直に書くと GitHub API を 10 回叩きます。キャッシュはまだ空だからです。
そこで「いま実行中の Promise」を Map に持っておき、同じキーの要求が来たら同じ Promise を返します。10 人が待つのは 1 回の API 呼び出しです。
コメントには落とし穴も書かれています。
完了時に必ず Map から削除しないと、エラー後に再試行できなくなる
失敗した Promise が残り続けると、その後の全員が失敗した結果を受け取ります。
404 はキャッシュしない
cacheable: (result) => result !== null,
「存在しない」という結果はキャッシュしません。リポジトリは後から作られることがある からです。存在しないという情報を 5 分キャッシュすると、作られた直後の 5 分間「無い」と言い続けます。
秘密情報を読んでよい場所を限定する
process.env を読んでいるファイルを数えてみると、6 ファイルだけ でした。
grep -rln "process\.env" src/
src/infrastructure/platform/session-cookie.tssrc/infrastructure/github/oauth.tssrc/infrastructure/github/installation-token.tssrc/composition/rate-limit.tssrc/composition/site-url.ts- ほか 1 件
これは規約ではなく、第 10 回で見る検査スクリプトが機械的に強制しています。 しかも、この検査だけは抑止コメントで無効化できないようになっています。
外の世界は HTTP とは限らない
ここまで GitHub API の話でしたが、src/infrastructure/platform/ には HTTP を一切叩かない実装もあります。static-gem-index.ts は GemIndexPort の実装のひとつで、読みに行く先は静的アセット(public/data/gem-index/ の JSON)です。
外の世界は HTTP とは限りません。 ファイル、静的アセット、環境変数、プラットフォームのバインディング。それでもポートの向こう側に置いてしまえば、呼ぶ側のコードは何も変わりません。
そしてこの実装には、第 6 回で見た「境界の引き方」がもう一段深い形で現れています。この 1 本のポートには lookup(バッジ用の所属照会)と search(一覧用の絞り込み)の 2 つのメソッドがありますが、search にしか要らない準備を lookup に払わせていません。理由はコメントにあります。
これを cold start に置くと、
SP-18で出荷済みの「検索結果ページの Gem バッジ」経路(lookupだけを呼ぶ)が、自分では使わないコストを isolate ごとに払う 既存機能の回帰 になる。
新しい機能を足すときに、既存の機能へその代金を回さない。 層を分ける理由と同じ発想が、1 ファイルの中でも使われています。
どうやって払わせずに済ませているか(2 段の遅延構築)
索引を用途ごとに 2 段に分けています。
| 段 | 中身 | いつ作るか | コメントに記録された実測 |
|---|---|---|---|
| プール | 小文字 repo 名 → エントリの Map | cold start(lookup / search のどちらでも) |
約 82ms |
| 検索インデックス | 照合用トークン列 + 並べ替え済みの配列 | 初回の search() のとき |
追加で約 122ms |
一覧を一度も開かない利用者のリクエストでは、2 段目は最後まで作られません。
実装上の注意も 2 つ書かれています。
- どちらの段も singleton(構築中の Promise をモジュールスコープに保持する)。同じ isolate へ並行到達したリクエストは同じ Promise を待つだけで、取得も tokenize も二重に走りません。この記事の前半で見た single-flight と同じ手です
- 失敗した Promise はキャッシュしない。失敗を抱え込むと、デプロイ直後の一時障害でその isolate が生きている間ずっとバッジも一覧も出なくなります
なお「cold start はデプロイ直後の 1 回だけ」ではありません。Workers では isolate ごとに繰り返し発生します。だから「起動時に 1 回だけだから多少重くてもいい」という見積もりが成立しません。
まとめ
- ACL は「外部の語彙をアプリへ持ち込ませない」境界
- 外部 API のフィールドは 使うものだけ 宣言する
- 検証ライブラリのエラーを外へ漏らさず、ドメインのエラーへ翻訳 する
- 同じ要件を複数箇所で守る(多層防御)
- キャッシュのような横断的関心事は、契約を変えずに包む
- 「存在しない」をキャッシュしない、といった判断もコードに残す
- この層が隠すのは HTTP だけではない。静的アセットを読む実装も同じポートの向こう側に置ける。そのとき「新機能のコストを既存機能に払わせない」は、層を分ける判断と同じ形で効いてくる
シリーズの前後の記事
- ⬅️ 前の記事: 第 8 回 DIコンテナを使わない依存性逆転
- ➡️ 次の記事: 第 10 回 依存規則を440行のPythonで機械検査する
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。