ゼスト Tech Blog

ゼストは「護りたい。その想いを護る。」をミッションに、在宅医療・介護業界向けのSaaSを開発しています。

レイヤー分けとの合わせ技で考えるコンポーネント実装のディレクトリ構成

こんにちは。株式会社ゼストでWebアプリケーションエンジニアをしている海老原です。

フロントエンド開発が進むにつれて、「このコンポーネントはどこに置くべきか?」「共通化すべきか、それともこの画面専用にすべきか?」と悩む場面は増えていくものです。
今回は、弊社プロダクト "ZEST Schedule" のフロントエンドにおいて、こうした迷いや誤った抽象化を防ぐために設計したディレクトリ構成と、その背後にある考えについてまとめてみました。

前提

ZEST Scheduleのフロントエンドでは Next.js (App Router) を使っています。

予定表画面のスクリーンショット

ZESTは在宅医療・介護領域向けの訪問スケジュールを軸としたSaaSで、ZEST Scheduleには日ごと、週ごと、月ごとなどの予定表に加え、シフト管理やサービスプランと呼んでいる予定の雛形のようなものを管理する画面があります。それぞれ1画面で対象となるオブジェクトの操作が完結するようになっており、画面あたりの情報量や機能が比較的多いアプリになっています。

また、画面の右側に”サイドメニューエリア”と呼んでいるペインがあります。
これは画面上で選択したオブジェクトの詳細情報を表示したり、その操作をする操作パネルのような役割を持っていて、ほぼ全ての画面に配置されます。サイドメニューエリアはこれ単体で独立したライフサイクルを持っており、メインの表示エリアと同じ画面にありながらルーティングによってサイドメニューエリアだけが画面遷移するような動きをします。

このようなデザインのため、1画面当たりのコンポーネント数はかなり多めです。
そのため、コンポーネントの依存関係であったり、コンポーネントがそれぞれどんな前提知識の上で実装されているのかが整理できるような、開発が進んでも複雑にならないようなディレクトリ構造を工夫する必要がありました。

コンポーネントのファイル構成

まず、1つのコンポーネント当たりに用意するファイル群を紹介します。
基本的には、1つのディレクトリ内に以下の4つのファイルをセットで管理しています。

ComponentName/
  ├── index.ts                  # エントリーポイント
  ├── ComponentName.tsx         # コンポーネント本体
  ├── ComponentName.css.ts      # スタイル定義
  └── ComponentName.stories.tsx # Storybook用

npm scriptのnew-componentコマンドを実行することで、これらのファイル群を生成できるようにしています。
今回お話しする中で重要なのは、1つのコンポーネントの実装につき、必ず同じ名前のディレクトリが切られる、というところです。

ディレクトリ構造とimportのルール

コンポーネントを作成する場所やimport先の決まりとして、主に次の2つをルールにしています。

  1. ディレクトリ、もしくはその直下のディレクトリから別のコンポーネントを import できる。
  2. ディレクトリの祖先ディレクトリ内にある_componentディレクトリからも別のコンポーネントを import できる。
  3. 1, 2以外からはimportしてはいけない。

このルールに従って実装すると、ソースツリーはこのような形になります。

schedule/
├── _component/               # schedule 配下でのみimport可能
│   └── ScheduleIcon/
├── MonthlyView/
│   ├── MonthlyView.tsx       # ScheduleIcon, EventLabel, DayCell(直下) をimport可能
│   ├── _component/           # MonthlyView 配下でのみimport可能
│   │   └── EventLabel/
│   └── DayCell/
│       ├── DayCell.tsx
│       └── Card/             # DayCellからはimport可能。MonthlyViewからは不可
│
├── WeeklyView/
│   └── WeeklyView.tsx        # DayCell(隣), EventLabel(MonthlyView配下の_component) はimport不可

このように、ディレクトリをまたいで使うようなコンポーネント_componentディレクトリに置いています。
ただ、ディレクトリをまたぐと言っても、またげるのはルール2に基づいて_componentディレクトリがある場所の配下だけになります。

ほぼ全てのコンポーネントがこのルールで作られています。この構造にすることの狙いは以下の通りです。

依存関係が見えやすくなる ディレクトリ構造を見るだけで、そのコンポーネントがどの範囲のコンポーネントに依存できるかが分かります。

読む範囲がはっきりする あるコンポーネントの実装を読むとき、どこまでのコードを追えば良いのかという境界線がはっきりします。

汎用かどうかが分かる 広く使われる共通コンポーネントにはファイルパスに_componentが付き、そうでないものには付きません。また、どの位置に_componentが付いているかによってどの範囲で汎用なのかが分かります。

ちなみに、_componentという名前はNext.jsのPrivate Foldersの命名規則に従ってつけたものです。

nextjs.org

関係する範囲をはっきりさせる

このディレクトリ構造で最も重要なことは、コンポーネントが関係する範囲(importしたりされたりする範囲)をはっきりさせることです。

フロントエンドのディレクトリ設計において、決まった数のレイヤーを事前に定義してコンポーネントを分類するアプローチは有用です。
私たちも、例えばButtonコンポーネントのような純粋なUI部品に関しては、プロジェクト全体で利用可能なUIレイヤーのコンポーネントとして管理しています。

ただ、そうした固定数のレイヤー分けだけだとフラットすぎて詳細なコンポーネントを実装していくうちに、どのコンポーネントが同じレイヤー内にある別のどのコンポーネントに依存するのかが追いづらくなることがありました。
開発が進むうちに1つのレイヤーの中でも依存関係に基づいたコンポーネントのグループのようなものが生まれてきて、ただそのグループは機能の追加や改修に伴って生まれるもので、事前に形式的に決めることが難しいのでプロジェクト単位で定義した固定数のレイヤーの中では扱いづらい。
前提セクションの通り、ZEST Scheduleは画面固有のコンポーネントが多く登場するアプリなのでこのような状況が多く生まれる可能性があり、それらも整理できるルールを考える必要がありました。

そこで、より具体的な事情をもとにしたレイヤー内レイヤーを階層的に切るようなイメージで、ディレクトリ構造を通じてコンポーネントがどこの範囲に関係するかが明確になるようにルールを考えました。
といっても、そこまで特別なものではなく、各エンジニアが何となく思っていることを整理したくらいの内容だとは思っています。

なぜこの設計なのか?

こうした設計にした背景には、「フロントエンドにおいてコンポーネントの抽象化は直感よりもずっと難しく、かつ変更が起きやすい」という考えがあります。
つまり、正しくコンポーネントを共通化するには考える時間がたくさん必要だし、それなりに間違えるし、プロダクトの進化に伴って正解不正解が変わることもある、ということです。

…これについて深掘りするとそれだけで記事ができてしまう(そしてもしかしたらXで延長戦があるかもしれない)ので、一旦これを認めた上で話を進めさせてください!

「フロントエンドにおいてコンポーネントの抽象化は直感よりもずっと難しく、かつ変更が起きやすい」という前提に立つと、このディレクトリ構造には次のような利点があると考えています。
これは日頃のコードレビューでも実感しているところです。

「間違った共通化のアラート」に気づきやすい コンポーネントの配置場所を考えたりコードレビューをする中で、_componentを目印にして「このコンポーネントって本当にこの範囲で共通化(抽象化)して大丈夫?」という問いが自然と生まれます。

配置場所の選択肢があって間違えづらい どこまでの範囲で共通とするか、どのレベルで抽象化するかの選択肢(自分のディレクトリ内、親の_component、さらに上の_componentなど)が多く用意されています。
適切な抽象化が難しい状況でも、「今のスコープならここに置くのがしっくりくる」と自信を持って配置できる場所が見つかりやすくなります。
同じレイヤーの中でも抽象度のグラデーションを持たせて調整できるようにすることで、結果として、設計での迷いや間違った抽象化を減らすことにつながります。

この設計が失敗する条件

この設計は共通化したくなったコンポーネントをどの_componentに置くかが肝です。
すぐに_componentに昇格させてしまったり間違えて広い範囲の_componentに配置することが増えると、範囲ごとに分割されているとはいえ_componentが太っていってしまいます。
このルールはUIレイヤーやドメインレイヤーのような、一般的な固定数のレイヤーを置いた上でそのレイヤー内で行うものなので、レイヤーをまたぐ失敗が起きることは少なくはありますが、_componentが太ると「やってる風」のルールになってしまい、間違った共通化が増え続けるのでこの状況は避けなければなりません。

1つの抑止力としては、前述した間違った共通化のアラートです。
「フロントエンドにおいてコンポーネントの抽象化は直感よりもずっと難しく、かつ変更が起きやすい」の認識をチームで共有することは前提として、それが発揮される機会がわかりやすいことはポイントです。

また、共通化はできそうだけどどの範囲の_componentに配置するか迷うときに、そもそも共通化しないという選択肢も含めて基本的には狭い範囲の_componentに配置するというような力学が働いていると失敗しづらくなります。
これは開発チーム内の勉強会やコードレビューを通じて認識合わせをしていく必要がある部分だと思います。どのようなディレクトリ構造をとっても同じことですが、あくまで良い設計をするための土台・ツールであって、実際に良い方向に向かうかどうかは使い手次第であることは忘れてはいけません。

まとめ

ZEST Scheduleでは、複雑な画面要件とコンポーネントの肥大化に対応するため、「関係する範囲をはっきりさせる」ことをテーマにしたディレクトリ構成を採用しています。

この記事のポイントをまとめると、以下のようになります。

  • 依存関係の可視化: ディレクトリ構造とimportルールによって、コンポーネントの影響範囲を明確にした
  • 抽象化の選択肢: 固定的なレイヤー分けに加え、_componentを用いたグラデーションのある抽象化ができるようにした
  • 運用の重要性: 構造を用意するだけでなく、チーム内で「安易に共通化しない」「配置に迷ったら狭い範囲に」といった共通認識を持つことが不可欠

なにか参考になることがあれば幸いです!