Feature-Sliced Designでフロントエンドのアーキテクチャを全社に導入してみた

Shunsuke Kimura

Shunsuke Kimura

SENDAI事業部 プロダクトマネージャー

株式会社STAR UPでプロダクトマネージャーをしております、木村です。

どこに何を書くかが決まらない

「一覧を取得して表示する」だけの処理が、画面ごとに違う書き方をしている。 レビューのたびに置き場所の議論をしていて、しかも前回と同じ結論に落ち着かない。 そんな経験はないでしょうか?

バックエンドには、オニオンアーキテクチャやクリーンアーキテクチャという有名なアーキテクチャがあります。 社内のバックエンドもオニオンアーキテクチャで、ドメインロジックをどこに置くか、外部との通信をどこに置くかなどは、だいたい決まっています。

フロントエンドには、こういった代表的なアーキテクチャが比較的少ないと感じています。

候補がないわけではありません。 ただ、どれもしっくりくるものがありませんでした。

Atomic Design1は、UIをatoms、molecules、organisms、templates、pagesの5階層に分ける方法論です。 決まるのは見た目の部品の粒度だけで、バックエンドとの通信やドメインロジックをどこに置くかは範囲の外にあります。

feature単位でディレクトリを切るfeature-basedな構成も、以前のプロダクトで使っていました。 ただ、これが決めてくれるのはfeatureという切り方までです。 粒度も、中の分け方も、feature間の参照も、自由に書けてしまいます。

featureの中でUIとドメインロジックが同居しはじめると、どこまでが表示で、どこからがロジックなのかが読み取れなくなります。

結果として、同じことをするコードが、人や画面ごとに違う形で書かれます。 1つのリポジトリの中でもばらつきますし、プロダクトが増えればプロダクト間でもばらつきます。 担当者が変わるたびに、また別の書き方が持ち込まれます。

こういったことを改善するために、どこに何を書くかが決まっている構造が必要でした。

Feature-Sliced Designを選んだ理由

Feature-Sliced Design2(以下FSD)は、フロントエンド向けのアーキテクチャです。 コードをレイヤー、スライス、セグメントの3階層で整理し、レイヤー間の依存方向を一方向に固定します。 FSDそのものの解説は公式ドキュメントと既存の解説記事が詳しいので、ここでは選んだ理由に絞ります。

1つ目の理由は、レイヤーがバックエンドの層と対応づけられることでした。

FSDのレイヤー置くものバックエンドで言えば
entitiesドメインモデルの型とAPIクライアントドメイン層
featuresユースケース単位のロジックアプリケーション層
widgets / page-components画面の組み立てプレゼンテーション層
sharedドメインを持たない汎用部品共通ユーティリティ

page-components はFSD公式にはないレイヤーです。置いた理由は後述します。

社内ではドメイン駆動設計を進めていますが、バックエンドでドメインモデルを整理しても、フロントエンドに置き場所がなければうまく連携できません。 entitiesは、そのドメインモデルの受け皿になります。 フロントエンドとバックエンドで別々の設計語彙を使わずに済むうえ、1人が両方を触ることも多いので、これは効きました。

もう1つの理由は、依存の制約です。 FSDでは、各レイヤーは自分より下のレイヤーだけを参照でき、同じレイヤーのスライス同士は参照できません。 この2つが揃うと、あるファイルを読むときに「どこから呼ばれうるか」の範囲が、ディレクトリの位置だけで決まります。 呼び出し元をリポジトリ全体から探し直す作業が減ります。

社内では複数のプロダクトにFSDを使っていて、レイヤー構成はこうなりました。

FSDの3階層と社内のレイヤー構成。app、page-components、widgets、features、entities、sharedの6レイヤーが並び、参照できるのは下向きだけ。スライスはドメインと機能の2階層で、中はui、model、api、libのセグメントに分かれる

公式と違うのは、pagesレイヤーの代わりに page-components を置いている点です。 公式の形から変えた箇所は、ここを含めて4つあります。

App Routerとpagesレイヤーの衝突

FSDにはpagesというレイヤーがあります。 一方、App Routerでは app/ 配下のディレクトリ構造がそのままURLになります。 この2つは名前と役割の両方が衝突するので、同居できません。

公式にはNext.js向けのガイドがあり、FSD側のレイヤーを _app と _pages にリネームして名前をずらす方法が示されています3。 FSDのレイヤーを src/ 配下にまとめて、Next.jsの app/ と物理的に分けるのも定番です。 社内でも後者は採りましたが、その先に「では app/ には何を書くのか」という問題が残ります。

リネームで済ませなかったのは、名前の衝突を避けるだけでは、この問題に答えが出ないからです。 決めたのは、app/ をルーティングの宣言だけに使い、画面の実体はすべて page-components に置くことです。 app/ 配下のファイルは、数行のラッパーになります。

// src/app/(authenticated)/products/page.tsx
import { ProductListContainer } from '@/page-components/product-list';

export default function ProductListPage() {
  return (
    <div className='flex h-full flex-col'>
      <ProductListContainer />
    </div>
  );
}

名前の衝突を避けるだけなら、ここまで徹底する必要はありません。 それでも画面の実体を全部移したのは、Server ComponentとClient Componentの境界を1か所に固定したかったからです。

App Routerでは、'use client' を書いた地点から下がClient Componentになります。 境界の深さが画面ごとにばらばらだと、あるコンポーネントがどちらで動くのかを、その都度たどらないと分かりません。 境界の位置が読み手の記憶に依存する状態です。

app/ をルーティングだけにして、page-components の入口で 'use client' を宣言する形にすると、境界は常に同じ位置に来ます。 「ページの実体に入ったらClient」とするだけで済みます。 app/ 配下はServer Componentのまま残ります。 境界を1か所に固定したのであって、Server Componentを使わなくしたわけではありません。

widgetsとui-blockの線引き

FSDのwidgetsは、featuresとentitiesを組み合わせた画面の部品を置くレイヤーです。 ところが実際に書き始めると、あるUIコンポーネントをwidgetsとpage-componentsのどちらに置くかで手が止まります。 どちらに置いても動くうえ、公式の定義だけでは判断がつきません。

そこで、次のルールを置きました。

  • 複数のページで使う共通の部品はwidgetsに置く
  • そのページでしか使わない部品は、page-componentsのスライスの中の ui-block に置く

page-componentsのスライスは、こういう構成になります。

page-components/
  product-list/
    ui/         # ページのコンテナ
    ui-block/   # このページでしか使わないサブコンポーネント
    lib/        # このページ固有のフック
    index.ts

このルールを入れた結果、widgetsに残ったのは、テーブル、フィルタバー、サイドバーのような共通UIだけになりました。 一方の ui-block は9つのページにあり、多いページでは24ファイルを抱えています。 ルールがなければ、この24ファイルもwidgetsに積まれていたはずです。

迷ったときは、いったん ui-block に置いて、2つめのページで必要になった時点でwidgetsへ上げます。 先に共通化しないほうが、結果としてwidgetsはきれいに保てました。

sharedが膨らむ問題

sharedは、appと並んでスライスを作らないレイヤーです。 ドメインを持たない汎用のUIとユーティリティが集まります。

スライスがないということは、区切りがないということでもあります。 放っておくと ui/ の直下にコンポーネントが並び続け、どこに何があるか分からなくなります。

対処として、セグメントの中をさらに分けました。

shared/
  ui/
    shadcn/         # UIライブラリ由来のプリミティブ
    form-fields/    # フォーム部品
    error-boundary/
    components/     # それ以外の汎用コンポーネント
  lib/
  utils/
  api/
  model/

セグメント自体も1つ足しました。 公式が標準として挙げるのは ui、api、model、lib、config の5つですが4、ここに utils を加えています。 lib との使い分けはこう決めました。

  • lib:状態やアプリケーションの文脈を持つもの。カスタムフック、APIエラーの解釈、計測処理など
  • utils:文脈を持たない純粋関数。日付や金額の整形、ファイルの変換、ストレージの読み書きなど

迷ったときの基準は、テストを書くときにReactが要るかどうかです。 要るなら lib、要らないなら utils に置きます。

featuresの階層の切り方

FSDの基本形では、レイヤーの直下にスライスがフラットに並びます。 featuresなら features/login/、features/create-order/ のように、機能ごとに1階層です。

この形のままだとスライスが際限なく増え、featuresの直下を開いても構造が読み取れなくなりました。

公式も、関係の近いスライス同士をフォルダでまとめること(グルーピング)は認めています。 条件は、そのフォルダをスライスとして扱わないこと、つまりフォルダの中でコードを共有しないことです4。 このグルーピングを例外ではなく標準として、ドメインと機能の2階層にしました。

features/
  product/
    list/
    detail/
    ranking/
  order/
    plans/
    review/

ドメインのフォルダの下に並ぶ list/ や detail/ を、以下ではサブスライスと呼びます。

最初のセットアップの時点でこの形にしています。 業務システムでは1つのドメインにつき機能が10を超えると分かっていたためです。 実際、あるプロダクトでは1つのドメインの下にサブスライスが14まで増えました。 フラットにしていたら、featuresの直下がこう並びます。

features/
  product-list/
  product-detail/
  product-ranking/
  order-plans/
  order-review/
  ...

どれが同じドメインの仲間なのかは、名前の接頭辞を読んで推測するしかありません。 ドメインでディレクトリを切っておけば、この推測が要らなくなります。

Public APIの単位は、ドメインではなくサブスライスに置きました。

import { useProductList } from '@/features/product/list';

ドメイン単位の index.ts で束ねてしまうと、1つの機能を使うだけでそのドメインの全機能を読み込むことになります。 スライスの粒度と、importの粒度は揃えたほうが綺麗です。 結果として、ドメインのディレクトリは束ねる入れ物であり、グルーピングの条件も満たします。

ここまでの4つの判断で、置き場所のルールは次の1枚にまとまります。

新しいコードをどこに置くかの判断フロー。ドメインを知らないものはsharedのlib、utils、uiへ、知っているものはentities、features、widgets、ui-blockへ分かれる

構造をテンプレートとして用意しておく

ここまでの判断は、1つのプロダクトの中だけで決めても価値が半分になります。 プロダクトごとに構造が違えば、担当が移るたび学び直しになるからです。

そこで、この構造を社内のフロントエンドテンプレートに入れました。 新しいプロダクトはテンプレートから始まるので、最初から同じ構造で立ち上がります。 いまは複数のプロダクトが同じレイヤー構成で動いています。

テンプレートに入れたのは、ディレクトリ構造だけではありません。 eslint-plugin-boundaries を使って、依存方向を強制するlintの設定も追加しました。

"boundaries/element-types": ["error", {
  "default": "disallow",
  "rules": [
    { "from": "shared",          "allow": ["shared"] },
    { "from": "entities",        "allow": ["shared"] },
    { "from": "features",        "allow": ["shared", "entities"] },
    { "from": "widgets",         "allow": ["shared", "entities", "features"] },
    { "from": "page-components", "allow": ["shared", "entities", "features", "widgets"] },
    { "from": "app",             "allow": ["shared", "entities", "features", "widgets", "page-components", "app"] }
  ]
}]

同じレイヤーのスライス同士を参照できないことも、この設定に含んでいます。 entitiesから許可されているのは shared だけなので、entitiesのスライス同士は参照できません。

entities同士が参照できないので、ドメインモデルの型がドメインをまたいで絡み合うことはありません。 複数のドメインをまたぐ処理が出てきたら、どちらのentitiesも参照できるfeaturesに書きます。

Public APIをどこまで強制するか

依存方向は、これで機械的に守られます。 では、各スライスがPublic APIだけを外に見せるという原則も、同じようにlintで強制すればよいのでしょうか。

こちらは単純にいきません。 原則を徹底するほど、再エクスポート用の index.ts(barrel)が増えます。 ドメイン単位の index.ts で挙げた問題が、あらゆる粒度で起きるということです。 広い範囲を1つのbarrelでまとめるほどTree Shakingが効かなくなり、ビルド時間が伸びます。 公式のPublic APIのページにも同じ問題が挙がっていて、index.ts を置いただけでは直接importを防げないことまで書かれています5。

そこで、スライスの外で使うものだけをexportし、内部で完結するものは公開しないようにしました。 それによって、必要最低限のモジュールを外部公開し、ビルド時間の短縮ができました。

AIに実装させるとどうなったか

この構造にしてから、Claude CodeやCodexに実装させたときの出力が安定しました。

理由は2つあると思っています。

1つは、レイヤー名がそのまま指示になることです。 「このロジックはfeaturesに、型はentitiesに」と書けば、置き場所の指定が済みます。 プロンプトで構造を毎回説明し直す必要がありません。

もう1つは、置き場所を間違えたときにlintで止まることです。 レイヤー違反のimportが書かれれば、レビューへ回る前にCIで落ちます。 lintが見ているのは依存方向だけですが、以前は人間が指摘していた種類の誤りが、レビューへ届く前に消えました。

おわりに

フロントエンドの書き方がばらつくのは、書く人の問題ではなく、どこに何を書くかが決まっていないからでした。 FSDはそこを決めてくれます。 ただし公式のままでは実運用に合わないので、社内向けに変えたのがこの記事で書いたことです。

いまでも、widgetsと ui-block の線引きでは判断が割れますし、lib と utils の境目にも迷います。 それでも、置き場所の議論が「どのレイヤーか」ではなく「このレイヤーの中のどこか」に狭まったのが、導入前との違いです。

アーキテクチャを決めておいたことで、AIに実装させる今のほうが、以前より速く、壊れにくいものが作れています。

Footnotes

  1. https://bradfrost.com/blog/post/atomic-web-design/ Brad Frostが2013年に提唱したUI設計の方法論。atoms、molecules、organisms、templates、pagesの5階層を定義している。 ↩

  2. https://feature-sliced.design/ Feature-Sliced Designの公式ドキュメント。レイヤーとスライスの定義、依存ルールがまとまっている。 ↩

  3. https://feature-sliced.design/docs/guides/tech/with-nextjs Next.js向けの公式ガイド。FSD側のレイヤーを _app、_pages にリネームする方法が示されている。 ↩

  4. https://feature-sliced.design/docs/reference/slices-segments FSDのスライスとセグメントの定義。標準のセグメントと、関係の近いスライスをフォルダでまとめる際の条件が書かれている。 ↩ ↩2

  5. https://feature-sliced.design/docs/reference/public-api Public APIの定義。index.ts を使う場合の循環参照、Tree Shaking、強制力の弱さといった問題も挙げられている。 ↩

Shunsuke Kimura

Shunsuke Kimura

SENDAI事業部 プロダクトマネージャー

// we are hiring

STARUPで一緒に働きませんか?

こうした技術的な挑戦を一緒に楽しめる仲間を募集しています。

採用情報を見る →