フロントエンドのディレクトリ構成を整理してコードの凝集度を高める
こんにちは、atama plus というスタートアップで web エンジニアをしている yubon です。
atama plus Advent Calendar 2023 の 7 日目になります。
本記事では、atama plus で実際に開発・運用している React プロジェクトにおいて、機能的な凝集度を高めるために行ったディレクトリ構成の再設計について紹介します。
フロントエンドのディレクトリ構成に関する考え方や設計思想は多くの記事で紹介されていますが、「業務で開発しているプロジェクトのコードで、ペインがある状態から再設計して実際に移行した」というケーススタディ的な記事は少なそうだったので、書き残しておこうと思います。
以前のペイン
今までは以下のようなディレクトリ構成でした。
src
├── components
│ ├── base # アプリケーションレイアウトを扱うコンポーネント
│ ├── common # アプリケーション全体で使用できる共通のコンポーネント
│ └── views # 各ページで使用するコンポーネント
├── constants # アプリケーション全体で使用できる共通の定数
├── contexts # アプリケーション全体で使用できる共通のcontext
├── hooks # アプリケーション全体で使用できる共通のhook
├── pages # ルーティングと1:1で紐づくページコンポーネント
├── types # アプリケーション全体で使用できる共通の型定義
...etc
開発を進めていると、以下の 4 つのペインが表出してきました。
- "共通"のディレクトリが色々な用途で使われることになり、位置付けが曖昧になる
- 用途の異なるファイルが同一ディレクトリにあることで、依存関係の制約を設けにくい
- 1 つのページを実装するために、多くのディレクトリを移動しなければならない
- 知識が分散しており、機能の把握や機能ごとの分割が難しい
それぞれ詳細に説明します。
1. "共通"のディレクトリが色々な用途で使われることになり、位置付けが曖昧になる
共通のディレクトリとして存在していた components/common
, constants
, types
, contexts
, hooks
に
- アプリケーション全体で使用するもの (=本当にグローバルなもの)
- 複数のページで呼び出されるだけのもの (=特定のドメインに紐づくもの)
の 2 種類が混在するようになり、位置付けが曖昧になっていきました。
とくに、components/common
に関しては
- 純粋な UI に関するコンポーネント (
Table
,Modal Dialog
など) - 特定のドメインに紐づくコンポーネント (生徒の学年の選択プルダウン, 問題の難易度を表すアイコン など)
の 2 つが混在していたため、果たして何のコンポーネントを置くべきディレクトリなのかわからない状態になっていました。
2. 用途の異なるファイルが同一ディレクトリにあることで、依存関係の制約を設けにくい
コード間の無茶な依存を防ぐために、ディレクトリ間でどのコードはどこから呼び出して良いかという依存関係の制約を設けようという話になりました。しかし、1.で述べたように共通のディレクトリに色々な用途のコードが存在するため、以下の例のような問題が発生しました。
- 同じディレクトリに
hoge.tsx
とfuga.tsx
というファイルが存在する -
hoge.tsx
のコンポーネントや hook はpages
でのみ import 可能にしたい -
fuga.tsx
のコンポーネントや hook はcomponents/views
でのみ import 可能にしたい
上記のようなケースではディレクトリレベルで依存関係を定義できないため、ファイル単位で定義することになります。そうなると、ファイル名などが変更されるたびに依存関係を再定義しなければならずサステナブルな状態ではないため、なかなか依存関係の制約を設けるに至りませんでした。
3. 1 つのページを実装するために、多くのディレクトリを移動しなければならない
atama plus では塾向けにアプリケーションを提供しています。塾の生徒が利用する学習アプリだけでなく、塾の先生方が利用する学習管理用の web アプリを開発しています。
たとえば、架空の「生徒の学習進捗を教科別で一覧で見られるページ(以下、学習進捗ページ)」を実装するとします。このページの要件は以下の 2 点です。
- 生徒の学習進捗状況を一覧で見ることができる
- 学習状況は、教科別に絞り込むことができる
このページを作るとした場合に、今までのディレクトリ構成では、実装したファイルは下記のような配置になります。
src
├── components
│ ├── base
│ ├── common
│ └── views
│ └── study-progress
│ ├── study-progress-list.tsx # 学習進捗を一覧で表示するコンポーネント
│ ├── subject-filter.tsx # 教科の絞り込みを行うコンポーネント
│ ├── study-progress.hook.ts # 生徒の学習進捗を取得するhook
│ ...etc
├── constants
│ └── study-progress.const.ts # 学習進捗に関する定数
├── contexts
├── hooks
│ └── subject.hook.ts # 教科を取得するhook
├── pages
│ └── study-progress-page.tsx # 学習進捗ページのページコンポーネント
├── types
│ └── study-progress.type.ts # 学習進捗に関する型定義
...etc
この配置で実装を進めていった場合に、たとえば
pages
→ components/views/study-progress
→ types
→ components/views/study-progress
→ hooks
→ ...
といったようなディレクトリの移動が必要になります。
IDE によって該当箇所にジャンプすることはできますが、少なくともファイルを新規作成する際はディレクトリを探さなければならず、生産性が高い状態とは言えませんでした。
4. 知識が分散しており、機能の把握や機能ごとの分割が難しい
1 つ前の 3.の「学習進捗ページ」の例を思い浮かべていただければわかりやすいのですが、学習進捗ページというページを把握するためには離れ離れになっているそれぞれのディレクトリを見に行く必要があります。そのため、キーワードで検索したり grep したりすることで、機能の全容を把握する感じになっていました。
また、事業が多角化していき、
- 事業ごとに使われている機能をコード上でひとまとまりで管理して、ゆくゆくは分割したい
- 分割した先の世界で、機能ごとの所有者を決めてコードに対する責任や権限を明確化したい
というニーズが社内で出始めていました。もし実際にコードを機能ごとに分割していくとなった時に、これだけ各ファイルが離れ離れになっていると分割は難しい状態になっていました。
再設計の方針と実際に進めた手法
ここまで述べてきたペインは、すべてコードの凝集度が低いことに起因するものでした。
そこで、まずはコードの凝集度を上げるために、ミクロなコードレベルでの凝集度に最初から着手するのではなく、一つ上のレイヤーのディレクトリレベルの凝集度を上げることから始めました。世の中のフロントエンドのディレクトリ構成におけるベストプラクティスを探していると、package by feature
という考え方があることを知りました。
package by feature
とは
最近フロントエンドの文脈でよく見るようになってきましたが、コードを技術的な属性 (= layer
) に基づいて分類・配置するのではなく、機能やドメインの関心 (= feature
) に基づいて分類・配置を行う設計方針です。(対となる設計方針はpackage by layer
と呼ばれています。)
package by feature
という単語自体は使っていませんが、以下の記事がアプリケーションの規模や開発フェーズに伴う、ディレクトリ構成の設計方針をよく表しているので参考にさせていただきました。
package by feature
に対応しているのは Exit: Group by Features という項になります。
package by feature
は、抱えていたペインをまさに解消してくれる考え方だったため、それを軸に再設計を行いました。
再設計したディレクトリ構成
feature
を機能的なまとまりの単位とし、特定のドメインの知識に紐づく共通のコンポーネントや hook、型定義などはfeatures
ディレクトリに配置することにしました。
その結果、再設計後は以下のようなディレクトリ構成になりました。 ([hoge]
は任意のディレクトリを表しています。)
src
├── contexts # 本当にglobalなcontextだけを残す
├── features
│ └── [feature] # feature単位でディレクトリを切る
│ ├── index.ts # Public APIとしてfeatureの中でインターフェースとして定義する対象を外部にre-export
│ └── ...etc # featureとして切り出せるコンポーネント,hook,型定義などを移動 (<-`components/common`,`components/views`,...)
├── hooks # 本当にglobalなhookだけを残す
├── layouts # アプリケーションレイアウトを扱うコンポーネント (<-`components/base`)
├── pages
│ └── [page] # ページ単位でディレクトリを切る
│ ├── index.ts # ルーティングと1:1で紐づくページコンポーネント
│ └── views
│ └── ...etc # ページ固有のコンポーネントを移動 (<- `components/views`)
├── ui # アプリケーション全体で使用できる純粋なUIコンポーネント (<-`components/common`)
...etc
変更点を要約すると、以下のようになります。
-
components/base
- →
base
という汎用性の高いディレクトリ名称をlayouts
というより限定的な名称に変更
- →
-
components/common
- → 位置付けが曖昧になっていたディレクトリからコンポーネントを
ui
とfeatures
に分類
- → 位置付けが曖昧になっていたディレクトリからコンポーネントを
-
components/views
- → 各ページで使用するコンポーネントを
pages/[page]/views
とfeatures
に分類
- → 各ページで使用するコンポーネントを
-
constants
,contexts
,hooks
,types
- → 本当にアプリケーション全体で使用されるもののみを残すようにし、不要なディレクトリは廃止
また、こだわった点としては[feature]
直下のindex.ts
です。このindex.ts
では、feature
の中でインターフェースとして定義する対象を Public API として外部に re-export することでfeature
の境界を明確にするようにしています。さきほど紹介した記事でも index.js as public API の項で言及されています。
得られたメリットと具体例
package by feature
に基づいた設計にしたことでコードの凝集度が上がり、以下のようなメリットが得られました。
- 機能ごとのまとまりが一目でわかるようになった (→ キャッチアップコスト減につながる)
- ディレクトリ間の移動が少なくて済む (→ 実装コスト減につながる)
- どのディレクトリに何があるかの位置付けが明確になった (→ 設計コスト減につながる)
具体的な例を見たほうがわかりやすいと思うので、再び以前のペインの 3.の項で出てきた「学習進捗ページ」を例にとります。「学習進捗ページ」のコンポーネントや hook は、再設計したディレクトリ構成だと以下のようになると思います。
src
├── contexts
├── features
│ └── subject
│ ├── index.ts # 教科の絞り込みを行うコンポーネントや教科を取得するhookを外部にre-export
│ ├── filter.tsx # 教科の絞り込みを行うコンポーネント
│ ├── hook.ts # 教科を取得するhook
│ └── ...etc
├── hooks
├── layouts
├── pages
│ └── study-progress
│ ├── index.ts # 学習進捗ページのページコンポーネント
│ └── views
│ ├── list.tsx # 学習進捗を一覧で表示するコンポーネント
│ ├── hook.ts # 学習進捗を取得するhook (型定義含む)
│ ├── constant.ts # 学習進捗に関する定数
│ ...etc
├── ui
...etc
このように
-
subject
(教科)というドメインに基づく知識に関するコンポーネントや hook -
study-progress
(学習進捗)というページに紐づくコンポーネントや hook など
がそれぞれ集約された上で綺麗に分離されて管理できるようになりました。
関連性があるファイルどうしが近づき、コロケーションの原則にも沿った形になっています。
feature
を抽出する基準
package by feature
を採用したものの、どのコンポーネント / hook をfeature
として抽出すべきなのか迷うタイミングがありました。今回の移行ケースのように、すでにあるコードから feature を抽出していくのは既存のコードのバイアスもあり、非常に難しいと感じました。
結論からお伝えすると、答えはドメインの内容やプロダクトの状況によると思います。
前提として、feature
として抽出する基準としては以下の 2 点があると考えています。
- 特定の概念やドメインに強く紐づいており、それをひとまとまりに保ちたい
- 複数の箇所から呼び出されるため、共有したい
たとえば、得られたメリットと具体例で登場した学習進捗を扱うコンポーネントや hook の例において、「学習進捗」という概念が他のページでも使用されるようなものである場合はfeature
としてstudy-progress
を切り出すのが良いと思います。また、データの取得部分だけは他のページでも使用されるというケースもあるので、データを取得する hook だけfeature
として切り出すという選択肢もあると思います。
feature
の対象はドメインの内容やプロダクトの状況などに大きく左右されるので、都度変化するものだと認識を持ち続けることが大切だと思います。
移行時に気をつけた点
全体設計の変更となるので、現在開発中のコードへの影響をできる限り少なくできるように細心の注意を払いました。
まずは rename したりディレクトリやファイルを move するだけでできそうなところからはじめました。そうすることで他のメンバーが開発中のコードとのコンフリクトをできる限り避けることができました。
その上で、既存のcomponents/views
からfeatures
に分類しなければいけない部分はファイルが大量に存在するため、一気に移行するのではなく、各メンバーで順次移行していくようなプロセスを取り決めて進めていました。
また、今回の内容には入れられなかったのですが、整備したディレクトリ(モジュール)を維持していくために、ESLint でモジュール間の依存関係を定義し、呼び出し元を制限することは引き続き行う予定です。
以下の 2 つの観点から呼び出し元を制限しようと考えています。
- 「
features
はpages
からしか読み込まない」といったモジュール間の依存関係の制限 - 「
features
からの import はindex.ts
から Public API として re-export されているのみにする」といったモジュールの境界を明確にするための import パスの制限
※ 1.にはナレッジワークさんの OSS であるeslint-plugin-strict-dependenciesをありがたく使わせていただいています。許可するパスを指定するため、直感的に理解しやすいです。
※ 2.は現在プラグインを選定している最中です。
まとめ
今回は、React プロジェクトにおいて、機能的な凝集度を高めるためにディレクトリ構成を再設計した話について紹介しました。皆さんが行っている、ディレクトリ構成のこだわり設計ポイントなどあれば聞いてみたいです。
これからもフロントエンドのアーキテクチャをよりよくしていけるように頑張りたいと思います!
明日は web エンジニア @yutake27 さんの「新卒 2 年目エンジニアが振り返る、チーム開発でやってよかったこと・やればよかったこと」です。明日もぜひご覧ください!
Discussion