これは何
この記事では、私がFeature-Sliced Design (FSD)というフロントエンドアプリケーション向けのアーキテクチャ設計方法を使っているプロジェクトにJoinして、約半年間開発に関わった経験から感じたことをシェアしたいと思います。
FSDについてはこちらの記事を参考にしてください。
めちゃくちゃざっくり言うと、抽象度ごとにLayerでグルーピングし、ドメインなどの意味のあるまとまりとしてSliceでグルーピングし、その技術的性質によってグルーピングするようなアーキテクチャ(ディレクトリ構成)になっています。
1. 抽象度(Layer)> 2. 意味のあるまとまり(Slice) > 3. 技術的性質(Segment)
今回FSDについて書きますが、FSDを使っていないプロダクトであっても、なにか活用できるアイデアや学び得てもらえたらと思い書きました。
結論
半年使った時点で総じてどう思っているかで言うと、
とても良いので今後も使いたい
です。そう思っている理由としては、以下のようなものがあり、これから説明していきます。
- いいと思っていること
- 同Layer間の依存が制限されており、依存関係がシンプルになる
- 「Public API」により、Sliceごとに機能をカプセル化しやすい
- Segmentにより、UIとロジックの分離がしやすい
- 依存の違反をLinterで厳格に防げる
- うーん...と思っていること
- 制約がきついので、どうしようか悩むことも多々ある
- index.tsが大量に発生する
前提: 使用したプロダクトについて
私が開発に携わっているアプリケーションでは、マネーフォワードクラウドにある複数のプロダクトを横断して利用される機能を開発しており、その機能をマイクロフロントエンドとしてプロダクト側に提供しています。
2年前の記事なので古くなっている部分もありますが、以下の記事はチームメンバーが書いたものです。
Reactを使っており、Next.js等のフレームワークは使用していません。比較的多くのstateを扱い、リッチな挙動をするページも持っているアプリケーションです。
いいと思っていること
1. 同Layer間の依存が制限されており、依存関係がシンプルになる
最初にFSDを学んで個人的に驚きだったのが、同Layerの依存が制限されていることです。
私はFSDを使う前にも抽象度ごとにLayerを切るアーキテクチャで開発をしたことがあったのですが、そこでは同じLayerの依存は許容されていました。
src
├-- LayerA
└-- LayerB
├-- featureA
├-- featureB
└-- featureC (A, Bから使われる機能)
例えば上記のように、featureAとfeatureBが似た処理 or 似たコンポーネントを使用しており、なんらか共通化したいという場合、featureCに出してそれを使うということがありました。ただ、これが増えていくと、同じLayer間でどのfeatureがどのfeatureに依存しているのかが分かりづらく、場合によってはfeature間で循環の依存が発生しうる状態でした。
FSDでは、これが完全に制限されており、再利用をするのであれば、1つ抽象度の高いLayerに切り出すか、1つ上のLayerから間接的に注入してもらうことでLayer間の直接の依存を防いでいます。
同Layerの依存が制限されていることで、1つ1つのSliceごとの役割が自然とシンプルで疎結合になり、変更時の影響が小さくできると感じています。
2. 「Public API」により、Sliceごとに機能をカプセル化、情報隠蔽しやすい
FSDには「Public API」というルールがあり、Sliceの内部に直接依存ができなくなっています。
たとえば、以下のような構成になっているとき、widgets/auth/ui
のコンポーネントから、features/auth-form/mode/logic1
のロジックを直接importすることは禁止されています。代わりに、features/auth-form/index.ts
をエントリーポイントとします。
src
├-- widgets
| ├-- auth
| | ├── ui ← ここからfeatureを使いたい
|
└-- features
├-- auth-form
| ├── model
| | ├── logic1 ← 直接importはNG
| | ├── logic2
| |
| └── index.ts ← ここをauth-formのfeatureのエントリーポイントとする
├--
これにより、features/auth-form
という単位でカプセル化をして、内部に処理やデータを隠蔽して、シンプルなインターフェースとして外部に公開することができます。
3. Segmentにより、UIとロジックの分離がしやすい
Segmentには、uiとmodel等のディレクトリがあります。各Sliceで使うロジックはカスタムhook等でmodel Segmentに切り出すことがルール付けられています。
src
└-- features
├-- auth-form
| ├── ui
| ├── model
これにより、UIとロジックをそれぞれ個別で単体テストをすることが自然と行えます。
カスタムhookをどこに置くかは話に上がることもありますが、個人的には特定の機能のみで使うUI/カスタムhookは、このように近くに配置してあるのがわかりやすいなと感じています。
4. 依存の違反をLinterで厳格に防げる
ここまで、Layerの依存方向やPublic APIといった制約について説明しましたが、これらはLinterを用いて自動で防ぐことが出来ます。
これのおかげで規則が厳格に守られるので、実装時もレビュー時も考えることが減ってありがたいです。
うーん...と思っていること
1. 制約がたくさんあるので、どうしようか悩むことも多々ある
すでにおわかりの通り、FSDの制約条件は多く、悩むことも多々あります。
たとえば、entity Layer間で共通のinterfaceを持ちたいとか、featureを使うためのちょっとしたロジックをどうするかとかなど、FSDのルール上明確に決まっていないことにたまに遭遇します。
それぞれ明確な答えがあるわけではないのですが、都度チーム内で議論をして、より良い方法を決定しながら開発をしています
2. index.tsが大量に発生する
これは「Public API」のルールの一貫でindex.ts
を用いて再exportをするというルールがあるためです。
これにより、単純にindex.tsを大量に作らなくてはいけないという面倒もありますが、意図せず循環参照を引き起こしたり、バンドルサイズが大きくなってしまったりする可能性もあります。
例えば、以下のようなディレクトリで考えます。そしてindex.ts
では、./ui
, ./model
, ./api
をimportしています。
このとき、ui, model, api内のファイルが他のSegmentをimportするために上のindex.tsをimportしてしまうと、読み込み順によっては循環importが発生する可能性があります。
features
└-- auth-form
├-- index.ts ← `./ui`, `./model`, `./api`をimportしている
├-- ui
| ├── ComponentA.tsx
|
├-- model
| ├── useHoge.ts
|
├-- api
| ├── hoge.ts
|
実際、私もこの問題にぶつかっており、以下の記事で説明しています。
バンドルサイズについてでいうと、Tree Shakingがうまくいかないケースもあったり、Tree Shakingを有効化していないDevelopment環境でもバンドルサイズが大きくなり再ビルド時間が伸びたりすることがあります。
まとめ
制約が色々とあり、最初のキャッチアップが大変だったりすることも想定されるので、全アプリケーションにマッチするわけではないと思いますが、もし興味を持った方はぜひ試してみてもらえると嬉しいです。
一度理解ができると、チームでの共通認識が築きやすく、Linterで制限できるので、大きく違反したコードが増えづらく、シンプルなコードを維持しやすいのでは?と思っています!