⛩️

Feature Sliced Design(フロントアーキテクチャ)でクリーンなアプリケーション構造を実現

2024/05/24に公開

はじめに

FSD(Feature-Sliced Design)アプローチを理解するために、その概念と設計方針を説明します。
既にFSDに関して基本的な知識がある方は、「参考」に記載している記事やgithubコードを参照すると理解を促進すると思います。
この記事では必要最小限のtodoアプリを例にとり説明します。

FSDの特性から下記をご認識ください。

  • フロントエンドアプリケーションを拡張性の高いものにするためのアーキテクチャ設計論になります。バックエンドへの適応は非推奨になります。
  • ユーザー向けのアプリケーションを対象としている為、ライブラリやUIキットへの適応は非推奨になります。

この記事のゴール

下記の観点を理解することを目標にします。

  • FSDの基本理解
  • FSDの設計概念の詳細
  • Todoアプリを用いて、具体的な適用方法
  • Next.js App router環境でのFSDの適用方法
  • FSDの良かったところを踏まえての総評

FSDとは

機能を「スライス」に分割する設計アプローチになります。
各スライスはそれぞれ独立して設計される為、機能の柔軟性や拡張性が向上します。
ユーザーアプリケーションであれば、ウェブ・モバイルその他のソフトウェア製品の設計に適用ができます。

sliced

設計概念

FSDは、レイヤー > スライス > セグメントという3つの概念と関係性で区別しています。
概念図2

レイヤー

https://feature-sliced.design/docs/reference/layers
概念図1

レイヤーは合計で7個あり、アプリケーションの1層目にあたります。
レイヤー同士でも依存関係が存在し、この階層構造は、レイヤーの依存関係を指し示します。

例えば、entitiesレイヤーでは、上位層にあるfeaturesの機能を利用することができません。一方でsharedレイヤーにあるものはimport可能です。

この階層構造により、レイヤー同士の依存関係は一方向に担保することができます

依存関係

階層内でレイヤーが下位に位置するほど、コード内のより多くの場所で使用される可能性が高いです。その為、下位のレイヤーを変更する際はリスクが高くなることに注意してください。

各レイヤーがどのように使用されるかのイメージをgithubを用いたものがこちらになります。

参照: https://dev.to/m_midas/feature-sliced-design-the-best-frontend-architecture-4noj

github

Processesレイヤーに関して

This layer has been deprecated. The current version of the spec recommends avoiding it and moving its contents to features and app instead.

Processesは現在非推奨レイヤーとなっています。その為、今回は説明を省きます。
もしこのレイヤーを利用する際は、featuresとappが利用できないか検討してみてください。

App

アプリ全体ロジックの初期化、router, provider, global styleなどが含まれます

app
├── globals.css
└── providers
    ├── index.ts
    └── redux-store.tsx

Pages

entities, features, widgetからページ全体を構築するための構成レイヤー。
場合によってはデータ取得ロジックとエラー処理が含まれています。
pagesは、その設計仕様と機能を備えた自己完結型(self-contained)のユニットとして開発されます。

Todoアプリでは、todoのリストを表示するホームページ (todo-list)、特定のタスクに関する詳細情報を提供するタスクの詳細ページ (todo-details)などが該当します。

pages
└── todo-home
    ├── index.ts
    └── ui
        ├── TodoHome.css.ts
        └── TodoHome.tsx

Widgets

entitiesとfeaturesにあるブロックを結合して構成するレイヤー。
ビジネスロジックは、基本的にに含まない。
再利用可能なコンポーネントを用意したい際に利用する。

Todoアプリでは、下記のようにリスト部分をwidgetsとして作成しているが、再利用する予定がない場合、pagesに記載してもよいと考える。

widgets
└── todo-list
    ├── index.ts
    └── ui
        ├── Todo.tsx
        ├── TodoList.css.ts
        └── TodoList.tsx

Features

ビジネス価値をもたらす機能を扱うレイヤー。
自己完結型の機能コンポーネントとして扱うことで、アプリケーションの主要な機能を独立した単位として整理し、他の部分との結合を最小限に抑えることが重要です。
これは、設計仕様と機能を備えたユーザーインターフェイスの一部と考えることができます。

これにより、保守性と拡張性が向上します。
Todoアプリでは、タスクのフィルターや、CRUDのUIが該当するかと思います。entitiesやsharedのロジックを用いて、独立した機能を達成します。

features
├── todo-create-form
│   ├── index.ts
│   └── ui
│       ├── TodoCreateForm.css.tsx
│       └── TodoCreateForm.tsx
└── todo-edit-form
    ├── index.ts
    └── ui
        ├── TodoEditForm.css.ts
        └── TodoEditForm.tsx

Entities

ビジネスロジックやモデルを扱うレイヤー。
静的UI, データストアやCRUD処理等がここに該当します。
Todoアプリでは、CRUDの処理とタイトル、内容、作成者を含んだTodo(ビジネスモデル)がここに該当します。

entities
└── todo
    ├── index.ts
    └── models
        ├── index.ts
        ├── selectors.ts
        ├── slice.ts
        ├── thunk.ts
        └── types.ts

Shared

プロジェクト/ビジネスの詳細から切り離されたレイヤー
特定のビジネスロジックに関連づけられていない再利用可能なコンポーネントやユーティリティが該当します。
アプリ全体で使用するuiコンポーネント, typeやapi等が該当します。
Todoアプリでは、フォームフィールド、投稿ボタンやapi等が該当します。

shared
├── api
│   └── todo
│       ├── index.ts
│       ├── todo.ts
│       └── types.ts
├── lib
│   └── store
│       ├── index.ts
│       ├── store.ts
│       └── types.ts
├── types
│   ├── apiStatus.ts
│   ├── errorType.ts
│   └── index.ts
└── ui
    ├── error-messages
    │   ├── ErrorMessages.css.ts
    │   └── ErrorMessages.tsx
    ├── index.ts
    ├── submit-button
    │   ├── SubmitButton.css.ts
    │   └── SubmitButton.tsx
    ├── todo-form-input
    │   ├── TodoFormInput.css.ts
    │   └── TodoFormInput.tsx
    └── todo-update-button
        ├── TodoUpdateButton.css.ts
        └── TodoUpdateButton.tsx

スライス

https://feature-sliced.design/docs/reference/slices-segments

Slices are the second level in the organizational hierarchy of Feature-Sliced Design. Their main purpose is to group code by its meaning for the product, business or just the application.

スライスは二番目の階層に当たるレイヤーになります。スライスの目的は、ビジネスライヤーに基づいてコードをグループ化することです。

スライスの階層はアプリケーションに依存する為、FSDは特に標準化していない為、プロジェクト毎に決定する必要があります。

また、スライスはお互い依存してはいけません。その為、画像のようなパターンはNGとなります。もし共有したいリソースがある場合は、構造を見直すか、sharedを活用する必要があります。
スライス

todoアプリのレイヤーとスライス構成の例

筆者は下記のように構成しました。

レイヤー スライス
entities todo
features todo-create-form, todo-edit-form
pages todo-home
widgets todo-list
app, shared ×

セグメント

https://feature-sliced.design/docs/reference/slices-segments

Segments are the third and final level in the organizational hierarchy, and their purpose is to group code by its technical nature.

セグメントは最後の3番目の階層です。セグメントの目的はコードを技術的な性質に基づいてグループ化することです。
ui/model/lib/apiと4つの標準化されたセグメントが存在します。。

  • ui — UIに関するコンポーネント
  • model — ビジネスロジック、データストレージおよびそのデータを操作する関数
  • lib — 補助コードおよびインフラストラクチャコード
  • api - 外部API、バックエンドAPIメソッドとの通信部分

カスタムセグメントについて

Custom segments are permitted, but should be created sparingly. The most common places for custom segments are the App layer and the Shared layer, where slices don't make sense

カスタムセグメントは許可されていますが、慎重に行う必要があります。基本的には、スライスが存在しないappレイヤーもしくは共有レイヤーで利用されます。

私のtodoでは、下記のようにapp/providersやshared/typesディレクトリをカスタムセグメントとして作成しております。

app
├── globals.css
└── providers
    ├── index.ts
    └── redux-store.tsx

shared
├── api
│   └── todo
│       ├── index.ts
│       ├── todo.ts
│       └── types.ts
├── lib
│   └── store
│       ├── index.ts
│       ├── store.ts
│       └── types.ts
├── types
│   ├── apiStatus.ts
│   ├── errorType.ts
│   └── index.ts
└── ui
    ├── ...

レイヤーとセグメントの関係性について(一例)

https://feature-sliced.design/docs/reference/slices-segments#segments

上記URLに記載されている「Example」を日本語で整理する。

レイヤー ui model lib api
shared uiコンポーネント × 複数ファイルで使用するutils 認証やキャッシュのような追加機能を持ったAPIクライアント
entities ビジネスモデルのスケルトン entityのインスタンスのデータストレージと、そのデータを操作するための関数。CRUD処理等が該当する ストレージに関連しないエンティティのインスタンスを操作する関数 SharedのAPIクライアントを使用したAPIメソッド
Features ユーザーが機能を使用する為のインタラクティブな要素 必要に応じて、ビジネスロジックとインフラストラクチャのデータストレージを操作する(現在のアプリのテーマなど) modelのセグメントに記載するビジネスロジックを簡潔に記述するのに役立つutils バックエンドでFeaturesを提供するAPIメソッド
Widgets entityとfeaturesを独立したUIブロックに構成する。エラー境界やロード状態も含む インフラストラクチャーのデータストレージ(必要な場合) ビジネスロジック以外のインタラクションや、ブロックがページ上で機能するために必要なその他のコード 基本的に使用しない
Pages エンティティ、フィーチャー、ウィジェットでページを構成する。また、エラー境界やロード状態も含む 基本的に使用しない ビジネス以外のインタラクション SSR指向フレームワーク用データローダー。初期データの取得等の処理が該当

Public API

各スライスとセグメントにはpublic APIが存在します。
Public APIを使用すると、インポートとエクスポートの操作が簡素化されるため、アプリケーションに変更を加えるときに、コード内のあらゆる場所でインポートを変更する必要がありません。

Public APIはindexファイルに公開モジュールを集約する場所となっています。スライスやセグメントから必要な機能だけを外部に抽出し、不要な機能を分離することができます。また外部ファイルからは、このindexファイルはエントリポイントとして機能します。

NGパターン1

外部のインターフェースが、モジュールの内部部分に直接アクセスしているパターン
改善メリット: 許可されているもののみをインポートするよう改善することで、アクセスされているモジュールが一目で判断できます。

/front/features/todo-create-form/index.ts
export { TodoCreateForm } from "./ui/TodoCreateForm";
- import { TodoCreateForm } from "@/front/features/todo-create-form/ui/TodoCreateForm";

+ import { TodoCreateForm } from "@/front/features/todo-create-form";

NGパターン2

モジュールの内部構造が変わった際に、外部から見えるパブリックAPIに影響を与えてしまうケース。
改善メリット: featureの内部構造が外部のユーザーには露出せず、コンポーネントの移動や名前の変更が外部のユーザーに影響を与えないようになります。

- import { Form } from "features/todo-create-form/ui/form"

+ import { TodoCreateForm } from "features/todo-create-form"

NGパターン3

名前の衝突を起こしてしまうケース。

/front/features/todo-create-form/index.ts
- export { Form } from "./ui/TodoCreateForm"
+ export { Form as TodoCreateForm  } from "./ui/TodoCreateForm"
/front/features/todo-edit-form/index.ts
- export { Form } from "./ui/TodoCreateForm"
+ export { Form as TodoEditForm } from "./ui/TodoCreateForm"

https://feature-sliced.design/docs/reference/public-api

Next.jsのApp router環境でFSDを適用する場合

NextJS App Routerは、appフォルダー内のファイルがURLと認識される特性があります。
このようなルーティング構造が存在する場合、純粋にFSDを適用することができません。
その際のアプローチの一つとして、下記のディレクトリ構造にするとFSDを適用することが容易に可能になります。

├── app                # NextJS app folder
├── src
│   ├── app            # FSD app folder
│   ├── entities
│   ├── features
│   ├── pages
│   ├── shared
│   ├── widgets

https://dev.to/m_midas/how-to-deal-with-nextjs-using-feature-sliced-design-4c67

Next.js app routerを用いたtodoの構成について

筆者は下記のように構成しました。

レイヤー スライス セグメント
entities todo models
features todo-create-form, todo-edit-form ui
pages todo-home ui
widgets todo-list ui
app × providers, globals.css
shared × api, lib, types, ui
todo構成
.
├── app
│   ├── api
│   ├── layout.tsx
│   ├── not-found.tsx
│   └── page.tsx
└── front
    ├── app
    │   ├── globals.css
    │   └── providers
    │       ├── index.ts
    │       └── redux-store.tsx
    ├── entities
    │   └── todo
    │       ├── index.ts
    │       └── models
    │           ├── index.ts
    │           ├── selectors.ts
    │           ├── slice.ts
    │           ├── thunk.ts
    │           └── types.ts
    ├── features
    │   ├── todo-create-form
    │   │   ├── index.ts
    │   │   └── ui
    │   │       ├── TodoCreateForm.css.tsx
    │   │       └── TodoCreateForm.tsx
    │   └── todo-edit-form
    │       ├── index.ts
    │       └── ui
    │           ├── TodoEditForm.css.ts
    │           └── TodoEditForm.tsx
    ├── pages
    │   └── todo-home
    │       ├── index.ts
    │       └── ui
    │           ├── TodoHome.css.ts
    │           └── TodoHome.tsx
    ├── shared
    │   ├── api
    │   │   └── todo
    │   │       ├── index.ts
    │   │       ├── todo.ts
    │   │       └── types.ts
    │   ├── lib
    │   │   └── store
    │   │       ├── index.ts
    │   │       ├── store.ts
    │   │       └── types.ts
    │   ├── types
    │   │   ├── apiStatus.ts
    │   │   ├── errorType.ts
    │   │   └── index.ts
    │   └── ui
    │       ├── error-messages
    │       │   ├── ErrorMessages.css.ts
    │       │   └── ErrorMessages.tsx
    │       ├── index.ts
    │       ├── submit-button
    │       │   ├── SubmitButton.css.ts
    │       │   └── SubmitButton.tsx
    │       ├── todo-form-input
    │       │   ├── TodoFormInput.css.ts
    │       │   ├── TodoFormInput.stories.tsx
    │       │   └── TodoFormInput.tsx
    │       └── todo-update-button
    │           ├── TodoUpdateButton.css.ts
    │           └── TodoUpdateButton.tsx
    └── widgets
        └── todo-list
            ├── index.ts
            └── ui
                ├── Todo.tsx
                ├── TodoList.css.ts
                └── TodoList.tsx

総評

明確なディレクトリ管理とほどよい疎結合性

FSDによりレイヤー、スライス、セグメントにより既に標準化された管理方法が整っています。
またレイヤー同士の依存関係が一方向であり、スライス同士は必ず疎結合になります。
その為、拡張性と変更による副作用が小さくなるという点で非常に優れているアーキテクチャと感じました。

叫ぶアーキテクチャ(Screaming Architecture)に近い

近年では、そのアプリケーションの目的やドメインをコードで明確に表現すべきと考える「叫ぶアーキテクチャ」が主流だと考えています。このアプローチのメリットは、コードの構成がその機能やビジネスロジックを直感的に反映しているため、プロジェクトの理解やメンテナンスが容易になることです。
ビジネスロジックも含め、アプリケーションの構造がその機能や目的を明確に反映すべきだという点で両者はかなり似ていると思います。

参考

文献

github

Discussion