Language: 日本語
「features ディレクトリ」が巨大なゴミ箱になっていませんか? 再帰的 features と ts-morph で依存を縛るフロントエンド設計
React や Next.js で開発しているみなさん、機能ごとにディレクトリを分ける **`features/` パターン(Bulletproof React 式)** を使っていますか?
React や Next.js で開発しているみなさん、機能ごとにディレクトリを分ける features/ パターン(Bulletproof React 式) を使っていますか?
コンポーネント、hooks、API クライアント、型定義を機能ごとに 1 つのディレクトリにまとめる手法で、多くのプロジェクトでデファクトスタンダードとして親しまれています。
しかし、プロジェクトが成長するにつれて、こんな事態に陥ったことはありませんか?
「features/ の直下に 30 個も 40 個もディレクトリが並び、どれが画面全体の機能で、どれが特定画面の小さなパーツなのか分からなくなった……」
最初は綺麗に整理されていたはずの features/ が、気づけば「何でも放り込まれる巨大なフラット空間(ゴミ箱)」と化してしまう現象です。
この記事では、フラットな features/ の限界を打ち破る 「再帰的 features(階層化)」 の構造と、人間の目視レビューに頼らず ts-morph(AST 解析)で不正な import を CI 上で 100% 機械的に防ぐガードレール設計 を解説します。
得られること
- 1 階層のフラットな
features/が破綻する理由と、ドメインの親子関係を表現する「再帰的 features」の構造がわかる - 「親 ➔ 子の
index.tsのみ許可」「兄弟間の直接参照は禁止」という明確な境界ルールの設計指針が学べる - ESLint では表現しにくい階層ルールを
ts-morphで CI 検査し、不正な依存を自動検知する仕組みが身につく!
フラットな features が抱える「巨大機能」のジレンマ
アノテーション基盤を開発していたとき、最大の肥大化要因となったのが AnnotationSession という機能でした。
作業者がタスクを開き、画像上にバウンディングボックスを引き、ラベルを付与して保存し、ショートカットキーで次のタスクへ進む……。この一連の作業に関わるコードは、すべて「アノテーションセッション」という 1 つの大きなドメイン文脈に属しています。
これを従来の「1 階層のフラットな features/」に収めようとすると、以下の二者択一で必ず破綻します。
【パターン A: 1 ディレクトリに全部詰め込む】
features/annotation-session/ の中に 50 個以上のファイルが同居して巨大化 💥
【パターン B: 無理やりトップレベルに細分化して並べる】
features/
├── annotation-session/
├── label-pane/ # ← 実は annotation-session でしか使わない!
├── task-navigation/ # ← 実は annotation-session でしか使わない!
└── shortcut-guide/ # ← 実は annotation-session でしか使わない!
何でもトップレベルに並んでしまい、「どこで使われる部品なのか」という文脈が消滅 💥
巨大化を避けるためにトップレベルに切り出すと、今度は「その画面でしか使わない専用部品」が全体公開のトップレベルに散乱し、依存関係がスパゲッティ化してしまいます。
解決策としての「再帰的 features」
そこで採用したのが、「feature の内側に、その文脈配下でしか使わない子 feature を再帰的にネストして配置する」 という設計です。
features/
└── annotation-session/ # 親 feature(トップレベル機能)
├── index.ts # 親の公開面(他の画面に公開する API)
├── AnnotationSession.tsx
└── features/ # 内部でのみ使う子 feature 群
├── label-pane/ # 子 feature
│ ├── index.ts # 子の公開面
│ └── LabelPane.tsx
└── task-navigation/ # 子 feature
├── index.ts # 子の公開面
└── TaskNavigation.tsx
再帰的構造のメリット
- 文脈がディレクトリ構造に残る:
label-paneやtask-navigationが「annotation-sessionという親機能の一部である」という所属関係がファイルツリーを見るだけで一目瞭然になります。 - トップレベルが汚染されない:
他の画面(プロジェクト一覧やダッシュボード)から誤って
label-paneを import してしまう事故を物理的に防げます。 - カプセル化と公開面の明確化:
各階層の
index.tsだけが外部に対する公開インターフェース(Facade)となり、内部の実装詳細は隠蔽されます。
import の境界ルール
再帰的 features を破綻させないために、以下の 厳格な境界ルール を定めました。
| 関係性 | import の可否 | ルール |
|---|---|---|
| 親 ➔ 子 | OK ⚡️ | 直下の子 feature の index.ts(公開面)のみ参照可能 |
| 子 ➔ 親 | NG ❌ | 子が親の内部実装を知る(逆流依存)は禁止 |
| 兄弟同士 | NG ❌ | 子 feature 同士が直接参照し合うのは禁止(親を経由する) |
| 内部への深掘り | NG ❌ | index.ts 以外の内部ファイル(例: ../label-pane/internal/Item)の直接参照は禁止 |
ts-morph による import グラフの自動検査
「ルールを策定して README に書きました! みなさんコードレビューでチェックしてください!」
……これで守られた試しがあるでしょうか? 残念ながらありません。
人間の目視レビューは必ずすり抜けます。しかし、この「親・子・兄弟という階層的な関係に基づく import 制約」は、ESLint の標準ルール(eslint-plugin-import 等の単純なゾーン指定)では動的に判定しづらいという問題がありました。
そこで、TypeScript の AST(抽象構文木)を自在に扱える ts-morph を使って、import グラフを CI で機械的に検査するスクリプト を構築しました。
// ts-morph を使った import グラフ検査スクリプトのイメージ
import { Project } from "ts-morph";
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
for (const sourceFile of project.getSourceFiles()) {
const filePath = sourceFile.getFilePath();
const importDeclarations = sourceFile.getImportDeclarations();
for (const importDecl of importDeclarations) {
const importPath = importDecl.getModuleSpecifierValue();
const resolvedPath = importDecl.getModuleSpecifierSourceFile()?.getFilePath();
if (!resolvedPath) continue;
// 1. 兄弟 feature の内部ファイルを直接参照していないか?
// 2. 孫 feature の非公開ファイルを深掘り import していないか?
// 3. 親への逆流依存が発生していないか?
if (isIllegalRecursiveImport(filePath, resolvedPath)) {
console.error(`❌ 不正な import を検知: ${filePath} -> ${importPath}`);
process.exit(1);
}
}
}
このスクリプトを CI(GitHub Actions)の PR チェックに組み込むことで、ルールに違反した import が 1 行でもあれば即座にビルドが落ちるようにしました。
再帰的 features が有効に機能する条件
もちろん、すべての機能を何でもかんでもネストすれば良いわけではありません。
- 子 feature にすべきもの: その機能(画面)の中でしか使わず、親と一緒に生まれ、親と一緒に成長・破棄される部品(例: アノテーション画面専用のツールバーやパレット)。
- トップレベルの feature に置くべきもの:
複数の画面から横断的に参照され、親から切り離しても単体でドメイン的価値を持つ部品(例:
projects,users,notifications)。
判断基準は 「その部品が、親の文脈から切り離しても独立して意味を持つかどうか」 です。
規約をテストとして固定する価値
アーキテクチャの境界ルールは、どんなに丁寧に文書化しても、機能追加や急ぎの改修が重なるにつれて必ず崩れていきます。
「手近な場所にある内部ファイルを相対パスで深く import してしまう」「便利だからと兄弟 feature のロジックを直接呼んでしまう」といったコードは、レビューの目視だけで防ぎ続けるには限界があります。
だからこそ、「アーキテクチャの規約を人間の努力目標にせず、CI が強制する機械的なテストとして固定すること」 が重要です。
ts-morph による AST 検査があれば、不正な import が入った瞬間に CI が落としてくれるため、レビュアーも設計の綻びを気にするストレスから解放されます。
大規模なフロントエンドを長く健全に保ち続けるためにも、「ディレクトリ構造の工夫」と「それを守る自動テスト」をセットで導入するのがおすすめです。