はじめに
新しく立ち上げた React + Vite プロジェクトのフォルダ構成を考えるにあたり、bulletproof-react をベースにしつつ、DDDのオニオンアーキテクチャの思想を取り入れてみました。
この記事では、その設計に至った経緯と考え方を整理します。
🚧 この記事は構築中のプロジェクトについて書いています
ここで紹介する構成は、まだ実装途中の設計方針です。実際に運用してみないと見えてこない歪みも多いと考えています。「ここはこうした方がいい」「この判断基準は破綻するのでは」といった指摘は絶賛募集中です。ぜひフィードバックをお寄せください。
参考にした記事
構成を検討するにあたり、以下の記事を参考にしました。
- bulletproof-react公式ドキュメント: project-structure.md
- Reactベストプラクティスの宝庫!「bulletproof-react」が勉強になりすぎる件(meijinさん)
- 本気で考えるReactのベストプラクティス!bulletproof-react2022(t_keshiさん)
meijinさんの記事では、featuresディレクトリについて、あるファイルをfeature配下に置くべきかどうかは「そのfeatureが廃止されたときに一緒に消えるものかどうか」で判断できるという考え方が紹介されており、features単位で分割する判断軸として非常に参考になりました。また、feature間の依存を防ぐESLintルールの実装例も、今回のfeature間依存の制御を検討する上で下敷きにしています。
t_keshiさんの記事では、bulletproof-reactのディレクトリ構成がAtomic Designを採用していない点に触れ、componentsが横断的(プロジェクト全体で再利用される)コンポーネント、featuresが限定的(特定の機能に閉じた)コンポーネントに対応するという整理がされていました。この「横断的/限定的」という切り口は、共有部分(components, hooks, libなど)とfeature固有部分をどう線引きするかを考える上で参考になりました。
なぜbulletproof-reactをベースにしたか
React はエコシステムが自由すぎるがゆえに、チームやプロジェクトごとにフォルダ構成がバラバラになりがちという課題があります。bulletproof-react は「銀の弾丸ではない」と明言しつつも、以下のような凝集性の高い設計方針を提示しています。
-
featuresフォルダを軸に、機能ごとにコードをまとめる -
appはルーティングやプロバイダなど、アプリの構成そのものに専念する -
components / hooks / lib / types / utilsのような技術的な共有パーツをsrc直下に配置する - feature間の直接importを避け、依存は
shared → features → appの単方向に統一する
この「機能単位でコードをまとめ、依存の向きを揃える」という思想が、チームで一貫した設計を保つ土台になると考え、採用しました。
なぜオニオンアーキテクチャも取り入れたか
bulletproof-reactのfeaturesフォルダは、あくまで「featureという単位で分割する」ことに主眼を置いており、feature内部の構造については強制力のあるルールを設けていません。
一方で、プロジェクトが育つにつれて次のような課題が見えてきました。
- APIの都合やUIの都合でビジネスルールが書き換えられてしまう
- 「このロジックはどこに書くべきか」がfeature内で曖昧になる
- テストを書くときに、UIとビジネスロジックが密結合していて分離しづらい
そこで、featureの内部構造にオニオンアーキテクチャの考え方を導入し、関心・責務ごとにレイヤーを明確に分けることにしました。
features/awesome-feature
├── domain # 最内層: Entity, Value Object, ドメインサービス
├── application # ユースケース + repositoryのinterface
├── infrastructure # 実装の詳細(API実装、状態ストア)
└── presentation # UIコンポーネント、Reactフック
依存の向きは常に内側に向かいます。
domain ← application ← infrastructure / presentation
-
domainはどの層にも依存しない、純粋なビジネスルールの塊です -
applicationはrepositoryのinterfaceのみを持ち、実装(DB操作やAPI呼び出し)を知りません -
infrastructureが実際のAPI・DB実装を担い、applicationのinterfaceを満たします -
presentationはUIとユーザー操作を扱い、applicationのユースケースを呼び出すだけです
これにより、「ビジネスルールを変更したいのに、UIやAPIの実装まで気にしないといけない」という状態を避けられます。ロジックの変更理由が「業務ルールの変更」なのか「技術的な都合」なのかで、触るべきレイヤーが自然に決まるようになります。
feature間の依存という現実的な壁
オニオンアーキテクチャの単方向依存の思想を各feature内に閉じ込めても、複数のfeatureをまたいで参照したくなる場面は必ず出てきます。
例えば「チーム管理機能からユーザーを強制退会させたい」というケースでは、teams機能がusers機能の情報やAPIを必要とすることがあります。
これに対しては、以下の考え方で整理しました。
-
feature間の依存は原則禁止とし、ESLintの
import/no-restricted-pathsで機械的に制限します - どうしても必要な場合は、片方向のみの例外を認めます(双方向依存は循環依存を招くため絶対に避けます)
- 例外を作る際は理由をコード上にコメントで残し、レビューを通常より厳しくします
- 例外が積み重なって「同じfeatureへの依存が繰り返し発生する」場合、それは設計を見直すシグナルとして扱います
共有カーネル(core)という選択
上記の「繰り返し依存が発生する」状態が実際に起きたため、Userのような複数featureから恒常的に参照される中核ドメインについては、featuresから切り出し、src直下にcoreという共有カーネル層を新設しました。
src
├── core
│ └── user
│ ├── domain
│ │ ├── user.ts
│ │ └── userService.ts
│ └── application
│ ├── getUser.ts
│ └── userRepository.ts
├── features
│ ├── teams
│ └── orders
domainという名前をそのまま共有カーネルの名前にしなかったのは、将来ユースケース(application層)まで共有したくなった時に名前が破綻するためです。coreは「featureと同じ内部構造を持つ、複数featureから参照可能な特別なfeature」と捉えることで、将来infrastructureやpresentation層が必要になっても同じルールで無理なく拡張できるようにしています。
依存ルールはシンプルに保っています。
core(domain → application)
↑
features/*/domain
↑
features/*/application
↑
features/*/infrastructure / features/*/presentation
coreはどのfeatureにも依存せず、全featureが自由にcoreを参照できます。これにより「例外」ではなく「正規ルート」として依存を扱えるようになりました。
appフォルダの整理
実装を進める中で、appフォルダの直下に単体のページコンポーネントや静的CSSが混在してしまう場面がありました。これに対しては、「featureに昇格すべきか」を安易に判断するのではなく、ビジネスロジックの有無を基準に整理しました。
- ロジックを持たない静的ページ(About, Privacy Policyなど)は
app/routes配下にページ単位でまとめます - お問い合わせフォームの送信ロジックなど、ロジックが複雑化してきたページは、その時点で
featuresへ格上げします
「単体コンポーネントだから即feature化」ではなく、「ロジックが複雑化したらfeature化する」という段階的な判断を採用することで、過剰設計を避けています。
命名規則について
フォルダ名は基本的に小文字(ケバブケース/キャメルケース)で統一し、Reactコンポーネントのファイル名のみPascalCaseとしています。router.tsxやprovider.tsxのような「アプリの構成を司るファイル」は小文字、AboutPage.tsxのような「コンポーネント本体」はPascalCase、という使い分けです。厳密なルールというよりは、プロジェクト内で一貫させることを重視しています。
最終的な構成
src
├── app
│ ├── app.tsx
│ ├── provider.tsx
│ ├── router.tsx
│ ├── routeMeta.ts
│ ├── site.ts
│ └── routes
│ ├── about
│ ├── contact
│ ├── home
│ └── privacy
├── assets
├── components
├── config
├── core
│ └── user
│ ├── domain
│ └── application
├── features
│ ├── teams
│ │ ├── domain
│ │ ├── application
│ │ ├── infrastructure
│ │ │ ├── api
│ │ │ └── stores
│ │ ├── presentation
│ │ │ ├── components
│ │ │ └── hooks
│ │ ├── types
│ │ └── utils
│ └── orders
│ └── (同様の構成)
├── hooks
├── lib
├── stores
├── styles
│ ├── breakpoints.css
│ ├── index.css
│ └── tailwind.css
├── testing
├── types
├── utils
└── main.tsx
まとめ
- bulletproof-reactの「機能単位でまとめ、依存を単方向に保つ」という凝集性の高い設計思想を土台にしました
- feature内部にはオニオンアーキテクチャを取り入れ、ビジネスルール(domain)を技術的な実装の詳細(infrastructure)から独立させました
- feature間の依存は原則禁止としつつ、現実的に必要な例外は理由を残して許可する運用にしました
- 複数featureから恒常的に参照される中核ドメインは
coreという共有カーネルに切り出し、依存を「例外」ではなく「正規ルート」として扱えるようにしました - ロジックのないページはfeature化せず、複雑化した時点で段階的に格上げする方針にしました
🚧 引き続き募集中
繰り返しになりますが、この構成はまだ実装途中であり、運用しながら見えてくる歪みは必ずあると思っています。改善案・指摘など、ぜひお気軽にコメントください。
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼