この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 8 回(全 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
対象読者は、依存性注入(DI)を「フレームワークが要るもの」だと思っているエンジニアです。ライブラリなしで依存性逆転を成立させる最小形を実例で見ます。
- ユースケースは「必要な部品を引数で受け取る関数」として書かれています
- DI コンテナもデコレータも
reflect-metadataも使っていません - セキュリティ要件を「呼び出し順の規約」ではなく「関数の内側」に置く 判断が読みどころです
ユースケース層がやること
usecases/ に置くのは「1 つの操作の段取り」です。
- 検索する(
search-repositories.ts) - 詳細を見る(
get-repository-detail.ts) - README を見る(
get-repository-readme.ts) - 日次ダイジェストを見る(
get-daily-digest.ts) - 候補プールを検索語で絞り込む(
search-gems.ts) - ログインを完了する(
complete-login.ts)
1 ファイル 1 ユースケース です。ファイル名を見れば、このアプリに何ができるかが分かります。
いちばん短いユースケース
import type { SearchResult } from '../domain/model/repository'
import { searchQuery } from '../domain/model/search-query'
import type { RepositoryQueryPort } from '../domain/ports/repository-query-port'
export type SearchRepositoriesInput = {
keyword: string
page?: number
sort?: string
perPage?: number
}
export type SearchRepositories = (input: SearchRepositoriesInput) => Promise<SearchResult>
/**
* キーワードでリポジトリを検索する(US-6)。
* 入力を値オブジェクト(`SearchQuery`)へ変換し、`RepositoryQueryPort` へそのまま委譲する薄い層。
*/
export function makeSearchRepositories(deps: {
repos: RepositoryQueryPort
}): SearchRepositories {
return async (input) => {
const query = searchQuery(input)
return deps.repos.search(query)
}
}
25 行です。 そして、この 25 行に依存性逆転の全部が入っています。
読みどころ 1: 関数を返す関数
makeSearchRepositories は、呼ぶと「検索する関数」を返します。
const searchRepositories = makeSearchRepositories({ repos: 実装 })
const result = await searchRepositories({ keyword: 'react' })
これが DI(依存性注入)の最小形 です。「検索する」という処理が、GitHub API を叩く具体的な実装を 自分で作らず、外から受け取っています。
Java や C# だとコンストラクタ注入とインターフェースで書くところですが、TypeScript では 引数 1 つで足ります。
読みどころ 2: 実装の名前が一切出てこない
import されているのは 3 つだけで、全部 domain/ からです。
GithubRepositoryQuery のような具体的なクラス名は、このファイルのどこにも出てきません。だから GitHub API の仕様が変わっても、このファイルは変えなくて済みます。
読みどころ 3: 境界で値オブジェクトに変換する
const query = searchQuery(input)
入口では生の string / number を受け取り、すぐに検証済みの値へ変換 しています(第 7 回)。これ以降のコードは「検証済みである」ことを前提にできます。
いちばん長いユースケースと比べる
「ユースケースは薄い層」と書きましたが、6 本の長さはまったく揃っていません。search-repositories.ts は 25 行、search-gems.ts は 298 行 です。10 倍以上あります。
この 2 本を見比べると、厚みがどこから来るかが分かります。
search-repositories.ts は 判定を 1 つも持っていません。何件だったか、失敗したのか、条件に合うものが無かったのか。すべてポートの返り値がそのまま答えになります。だから値オブジェクトへ変換して渡すだけで終わります。
search-gems.ts はそうではありません。相手のポートは失敗しても例外を投げず空の結果を返す契約なので、totalCount === 0 だけでは「取れなかった」と「一致が無かった」を区別できません。そこでこのユースケースは、返す値を 4 通りに分けます。
- 取得に失敗した(
status: 'failed') - 検索語からそもそも照合に使えるトークンが取れなかった(
unmatchableQuery: trueの 0 件) - 照合はできたが候補プールに載っていなかった(
unmatchableQuery: falseの 0 件) - 一致があった
区別する理由もコメントに書かれています。
画面が一時障害を「あなたの検索語には Gem が無い」と誤って伝えてしまう
薄いのは委譲だけのとき、厚くなるのは判定を持つとき。 そして厚くなった 298 行の中身は「GitHub のこと」でも「JSON のこと」でもありません。あるのは 利用者に説明すべき状態の区別 だけです。層の役割は変わっていません。
逆に言えば、ユースケースが厚くなったときに疑うのはここです。増えたのが状態の区別なら妥当ですが、増えたのが HTTP のリトライやレスポンスの整形なら、それは下の層へ落とすべきものです。
ポートとは何か
RepositoryQueryPort は domain/ports/ にあるインターフェースです。
export interface RepositoryQueryPort {
/** 検索条件に合致するリポジトリの一覧を取得する。 */
search(query: SearchQuery): Promise<SearchResult>
/** 単一リポジトリを owner/repo で取得する。存在しない場合は例外にせず null を返す(404 → null)。 */
findDetail(name: RepositoryFullName): Promise<RepositoryDetail | null>
/**
* 単一リポジトリの README を GitHub がレンダリング済みの HTML 文字列として取得する。
*
* 🔴 戻り値は **未サニタイズの第三者由来 HTML** である。表示前に必ずサニタイズすること
* (`src/ui/` 側の責務。ACL は取得のみを行い、表示都合の加工を持ち込まない)。
* 🔴 非公開リポジトリの遮断は本メソッドでは行えない(README のレスポンスに `private` が無い)。
* 呼び出しは必ず `findDetail` の判定を通す usecase 経由にする。
*/
findReadme(name: RepositoryFullName): Promise<string | null>
}
インターフェースなので 実装は 1 行もありません。書いてあるのは「何ができるか」だけです。
そして注目してほしいのは コメントの量 です。ここには「型では表現できない契約」が書かれています。
-
findDetailは 404 で例外を投げずnullを返す。「無かった」は異常事態ではなく正常な結果、という判断 -
findReadmeの戻り値は危険な HTML である。サニタイズは呼び出し側の責任、と明記 -
findReadmeだけでは非公開リポジトリを弾けない。だから必ず usecase を経由しろ、と警告
型は「文字列を返す」としか言えません。「その文字列は信用できない」までは型に書けない ので、契約としてコメントに書いてある。これが「機械で守れないものはレビュー観点にする」(第 6 回)の実例です。
ポートは 7 つ
src/domain/ports/ にあるインターフェースは 7 つです(ファイルも 7 つ)。
| ポート | できること |
|---|---|
RepositoryQueryPort |
search / findDetail / findReadme
|
CachePort |
get / set / invalidate
|
RateLimitPort |
consume |
ClockPort |
now |
AuthPort |
exchangeAuthorizationCode |
GemDigestPort |
listCandidates |
GemIndexPort |
lookup(候補プールに載っているかの所属照会)/ search(候補プールの絞り込み一覧) |
そして運用ルールがあります。
ポートを増やすときの条件:
W-1〜W-3(第 6 回)のどれを守るかを 1 行で書き、表に行を足す。表に無いポートを実装しない。
面白いのは、README 取得機能を追加したときの判断です。新しく ReadmePort を作るのではなく、既存の RepositoryQueryPort にメソッドを 1 つ足しました。
ポートを増やすほど「差し替えられる単位」は細かくなりますが、同時に配線が増え、読む人が追う経路も増えます。だから面積を広げない。
ここで小さなドリフトを見つけました。 設計ドキュメントのポート一覧表には 6 行 しか載っていませんが、実装には 7 ファイル あります。表から抜けているのは日次ダイジェスト機能で追加された
GemDigestPortで、表への追記が漏れています。そして注目すべきなのは、表の側も動いている ことです。あとから追加された
GemIndexPortは、実装と同じタイミングで表にも追記されました。同じ運用が片方では漏れ、片方では機能したわけです。ルールが空文になったのではなく、人間が回す運用の取りこぼし率がそのまま出ています。ドキュメントと実装は、放っておくと必ずずれます。依存規則のように機械検査があるものはずれませんが、「表に行を足す」のような人間の作業は漏れます。読むときは「ドキュメントに書いてあること」と「実装がそうなっていること」を分けて確かめてください。
ClockPort — 時刻すら外から渡す
export interface ClockPort {
now(): Date
}
「現在時刻を返す」だけのインターフェースです。大げさに見えますが、理由があります。
Date.now() を直接呼ぶコードはテストできません。 「1 時間後に期限切れになる」処理をテストするのに、1 時間待つわけにはいきません。
時刻を外から渡す形にしておけば、テストでは好きな時刻を渡せます。第 14 回で実物を見ます。
セキュリティ要件をどこに置くか
いちばん読む価値があるのが get-repository-readme.ts です。
/**
* README 取得の private ゲート(`NFR-33` / `AC-12`)。
*
* 🔴 `findReadme` のレスポンスには `private` フィールドが無く、それ単体では非公開リポジトリを
* 判別できない。必ず `findDetail`(公開判定済み・非公開なら null を返す ACL)を先に経由し、
* `null` なら `findReadme` を **呼ばずに** null を返す。呼び出し元の順序に依存させないため、
* このゲートは usecase 内に埋め込む(`get-repository-detail.ts` を先に呼ぶ規約に頼らない)。
*/
export function makeGetRepositoryReadme(deps: {
repos: RepositoryQueryPort
}): GetRepositoryReadme {
return async (input) => {
const name = tryRepositoryFullName(input.owner, input.repo)
if (name === null) {
return null
}
const detail = await deps.repos.findDetail(name)
if (detail === null) {
return null
}
return deps.repos.findReadme(name)
}
}
やっていることは「README を取る前に、まず詳細を取って公開かどうか確かめる」です。API を 2 回叩いています。
効率だけ見れば無駄です。呼び出し側で「先に詳細を取ってから README を取る」と決めておけば 1 回で済みます。
それをしなかった理由がコメントの最後の一文です。
呼び出し元の順序に依存させないため、このゲートは usecase 内に埋め込む(
get-repository-detail.tsを先に呼ぶ規約に頼らない)。
「規約に頼らない」。 順序を守る規約は、守られなかったときに気づけません。半年後に別の画面から getRepositoryReadme を単体で呼ぶ人が現れたら、非公開リポジトリの README が漏れます。そのときエラーは出ません。
だからゲートを関数の内側に埋め込んで、どこから呼ばれても必ず通る ようにした。API 1 回分のコストは、その保証の対価です。
セキュリティ要件は「守る場所」ではなく「守れる構造」で担保する。 これはどの言語・どのフレームワークでも通用する考え方です。
まとめ
- ユースケースは「部品を引数で受け取る関数」。DI コンテナは要らない
- ポートには「型に書けない契約」をコメントで書く(この戻り値は危険、404 は null、など)
- ポートの数を増やしすぎない。既存のポートにメソッドを足せないか先に考える
- ユースケースの厚みは、持っている判定の数で決まる。委譲だけなら 25 行、状態を 4 通りに区別するなら 298 行になる
- 時刻のような「テストしにくいもの」も外から渡す
- セキュリティ要件は呼び出し規約ではなく、関数の内側に埋める
シリーズの前後の記事
- ⬅️ 前の記事: 第 7 回 ブランド型で「検証済みの値」を型にする
- ➡️ 次の記事: 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。