この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 10 回(全 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
対象読者は、設計ルールを決めても守られずに形骸化した経験があるエンジニアです。ルールをスクリプトに落として機械で守らせる実例を、実際に壊しながら見ます。
-
newを書いてよい場所をsrc/composition/配下に限る(composition root) - 依存規則の正本は Python の dict 1 個。440 行のスクリプトが全ファイルを検査します
- 実際に規則を破って、検査が落ちるところまで再現 します
new を書く場所を src/composition/ に閉じる
第 8 回で、ユースケースは部品を引数で受け取る関数だと見ました。では、その部品は誰が作るのか。
答えが src/composition/ です。設計ドキュメントの規則は 実装をポートへ束ねる場所は src/composition/ 配下に限る(関心ごとにファイルを分けてよい)。境界はファイルではなくディレクトリで引かれていて、実際このディレクトリには container.ts・rate-limit.ts・auth.ts・site-url.ts が並んでいます。中心が container.ts で、infrastructure/ を import してよいのはこのディレクトリの中だけ です。
function makeCachingRepositoryQuery(deps = {}) {
const clock = new SystemClock()
return new CachingRepositoryQuery({
inner: new GithubRepositoryQuery({ token: makeTokenProvider(clock, deps.accessToken) }),
cache: sharedCache,
ttlSeconds: { search: TTL_SEARCH_SECONDS, detail: TTL_DETAIL_SECONDS },
onCacheStatus: deps.onCacheStatus,
})
}
export function searchRepositoriesUseCase(accessToken?: string | null): SearchRepositories {
return makeSearchRepositories({ repos: makeCachingRepositoryQuery({ accessToken }) })
}
GithubRepositoryQuery(GitHub API を叩く実装)が CachingRepositoryQuery(キャッシュのデコレータ)に包まれ、それがユースケースへ渡されています。組み立ての全部がここに見えています。
画面のコードは searchRepositoriesUseCase() を呼ぶだけで、中身が何層に包まれているかを知りません。この配線盤には Gem 系の配線(lookupGemIndexes / searchGemsUseCase)も同じ形で並んでいます。
製品判断の数値が 1 箇所に集まる
このファイルには定数も置かれています。
/**
* 検索結果のキャッシュ TTL(秒)。60 秒は暫定値(`R-5` のレート枠逆算が未確定のため)。
* `R-5` が確定したら本値を見直す(現時点では「同じキーワードで数十秒以内に連打しても
* GitHub API を叩かない」を満たす最小の値として置いている)。
*/
const TTL_SEARCH_SECONDS = 60
/**
* リポジトリ詳細のキャッシュ TTL(秒)。詳細情報は検索結果より更新頻度が低いと見なし
* 検索より長め(5 分)に設定した暫定値。根拠・再決定条件は上と同じ。
*/
const TTL_DETAIL_SECONDS = 300
/** トップページの日次ダイジェスト表示件数(ADR 0014 §2.1 の既定 5 件)。 */
export const DAILY_DIGEST_LIMIT = 5
「暫定値である」と明記され、「いつ見直すか」の条件まで書いてあります。
マジックナンバーの問題は、値そのものより「なぜその値なのか、いつ変えていいのか」が失われることです。ここでは「暫定」「根拠」「再決定条件」の 3 点セットで残っています。
実行環境の癖もここに書く
/**
* isolate 内で使い回すキャッシュの単一インスタンス(モジュールスコープ)。
* 関数内で `new` すると呼び出しのたびに空の `Map` になり常に MISS になるため、
* モジュール読み込み時(isolate 起動時)に 1 回だけ生成する。
*/
const sharedCache: CachePort = new InMemoryCache(new SystemClock())
関数の中で new すると、キャッシュが毎回空になって全部 MISS します。 エラーは出ません。ただ遅くなり、API 呼び出しが増えるだけです。
こういう「動くけれど意図どおりでない」状態は、気づくのが最も難しい種類の不具合 です。だから理由をコメントに残してあります。
規則を機械で守る
ここからが本題です。第 6 回で見た依存規則は、文章としてだけでなく Python スクリプトで検査されています。
正本は dict 1 個
440 行のスクリプトの心臓部がこれです。
FORBIDDEN_TARGETS: dict[str, tuple[str, ...]] = {
"domain": ("usecases", "infrastructure", "ui", "composition", "app"),
"usecases": ("infrastructure", "ui", "composition", "app"),
"infrastructure": ("usecases", "ui", "app"),
"ui": ("usecases", "infrastructure", "app"),
"app": ("infrastructure",),
"shared": ("domain", "usecases", "infrastructure", "ui", "composition", "app"),
}
第 6 回の表がそのままコードになっています。表とコードが別々に存在すると必ずずれる ので、コードを正本にして表はその写しにする、という関係です。
ただし domain だけは特別扱いで、禁止リストではなく許可リスト として検査されます。domain は外部パッケージ(React も zod も)も import できないので、「禁止するものを並べる」方式では漏れるからです。
実際に走らせてみる
正常な状態で走らせるとこうです。
python3 tools/check_architecture_boundaries.py
✅ 依存規則 OK(176 ファイル・Warning 0 件)
わざと壊してみる
規則を破るファイルを作ってみます。ドメイン層から Next.js の関数を import する、という違反です。
import { notFound } from 'next/navigation'
export function sample(): void {
notFound()
}
この状態でもう一度走らせます。
python3 tools/check_architecture_boundaries.py
❌ src/domain/model/_experiment.ts:1 ARCH-1: ドメイン層は `next/navigation` を import できません(src/domain/ と src/shared/ のみ)
依存規則違反 1 件 / 検査 177 ファイル。SSOT: docs/03_design/architecture/application-architecture.md §1.2
終了コードは 1 です。CI で走らせれば、この時点で止まります。
エラーメッセージに 3 つの情報が入っているのが親切です。
- どのファイルの何行目か
-
どの規則に違反したか(
ARCH-1) -
正しい状態は何か(
src/domain/とsrc/shared/のみ)
そして最後の行に 設計ドキュメントへの参照 があります。「なぜこの規則があるのか」を知りたい人が辿れる導線です。
この検証は実際に手元で再現できます。src/domain/model/ に上記の内容でファイルを作り、スクリプトを走らせて、確認後に消してください。規則が本当に守られているかは、破ってみるのがいちばん確実です。
抜け道を「わざと」塞いでいない箇所がある
このスクリプトには // arch-ok という 抑止コメント があります。付けると、その行の違反が見逃されます。
例外を認める仕組みは必要です。設計は完璧ではなく、どうしても破らざるを得ない箇所は出てきます。
ただし、抑止が効かない検査が 2 つだけあります。
| 規則 | 内容 | 抑止 |
|---|---|---|
| ARCH-1〜3, 6, 7 | 層をまたぐ import の禁止 |
// arch-ok で抑止 できる
|
| ARCH-4 | 事業者固有の機能に触れてよい場所の限定 | できない |
| ARCH-5 | 秘密情報を読んでよい場所の限定 | できない |
理由は明快です。秘密情報とベンダー境界は、抜け道を作ると意味がなくなる からです。
さらに、抑止した件数は 必ずサマリーに出力されます。「例外を認めるが、黙って消しはしない」という設計です。
実装から学べること
このスクリプトには、教材として面白い記録がいくつも残っています。
正規表現のバックトラック暴走
import 文を抽出する正規表現に、こんなコメントがあります。
[^'"();]に|\nを足すと同じ 1 文字に 2 経路ができ、破滅的バックトラック(実測 57 秒)
正規表現に 1 文字足しただけで、検査に 57 秒 かかるようになった、という実測記録です。同じ文字にマッチする経路が 2 つあると、正規表現エンジンが組み合わせを全部試してしまいます。
コメントの中の URL で誤検知しない
// https://api.github.com/... を参照
こういうコメント行があると、素朴な文字列マッチでは「GitHub API に触っている」と誤検知します。そこでスクリプトは、文字列リテラルを保護しながらコメントだけを空白に置換 してから検査します。行番号がずれないよう、削除ではなく空白に置き換えているのが細かいところです。
実行場所によって結果が変わらないようにする
パスの正規化に Path.resolve() ではなく os.path.normpath を使っています。理由は「カレントディレクトリに依存させないため」。フックから呼ばれたときと手で叩いたときで結果が変わると、検査の信頼性が落ちます。
検査スクリプト自身にテストがある
python3 tools/check_architecture_boundaries.py --self-test
38 件 のケースが内蔵されています。「この内容のファイルを検査したら、エラーが何件出るはずか」を文字列で与えて検証する形なので、実際にファイルを作らずにテストできます。
これができるのは、検査の中核が I/O を持たない純粋な関数 として書かれているからです。検査するものにも検査が要る、という当たり前のことが実装されています。
自分のプロジェクトに持ち帰る
この仕組みは Python である必要も、440 行である必要もありません。最小構成はこうです。
- 依存の向きを表にする(第 6 回)
- 表をコードにする(dict 1 個)
- import 文を抽出して照合する
- 違反があったら終了コード 1 で落とす
- CI で走らせる
JavaScript なら ESLint の no-restricted-imports でも近いことができます。大事なのは道具ではなく、規則が破られたときに機械が止めること です。
まとめ
-
newを書く場所をsrc/composition/配下に閉じると、組み立ての全体が 1 ディレクトリで見える - 定数には「暫定値である」「いつ見直すか」まで書く
- 依存規則の正本を dict 1 個 にして、表はその写しにする
- 例外を認める抑止コメントを用意しつつ、秘密情報とベンダー境界には効かせない
- 検査スクリプト自身にもテストを書く
最後に 1 つ、この回の外へ続く話をしておきます。ここで見たのは「規則をデータとして持ち、機械に見張らせる」という形でした。gem-hunter でこの形をとっているのは、依存規則の検査だけではありません。同じ作りのゲートが他にもいくつか置かれていて、どれも「人が覚えて守る」ではなく「破ったら終了コードで落ちる」に寄せてあります。何がどれだけあるのか、それらをどう 1 コマンドに束ねているのかは 第 19 回 でまとめて扱います。
シリーズの前後の記事
- ⬅️ 前の記事: 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- ➡️ 次の記事: 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。