Next.js/App Router を CleanArchitecture風に構築してみた

! 本記事は、ある程度フロントエンド開発/Next.js/App Routerの知見がある方を対象としています。
! React.js に当てはめた理論となっています。Vue, Angular 等の別FWでの検証は行っていませんが、概ね当てはめられるかと思います。
今回構築するシステムの概要は以下です。そこまで重要ではないので、さらっと読み流してよいです。
  • 家計簿アプリ / household-manager の構築
  • Frameworkは、Next.js/App Router を採用する
  • dashboard ページに、残高と登録履歴が表示される
  • UI Library は、 および  を使用する

1章 結論

導入を書いていると、長くなるので、結論からブレークダウンしようと思います。

1-1. アーキテクチャ図

詳細は後述しますが、本家  と得られる恩恵は同じで フロントエンドを構成する要素同士の 関心の分離 をすることで、外部ライブラリの置き換え・バージョンアップを容易に対応できるようにする ことを狙っていきたいと思います。
! あくまでも理想論であり、円図の依存方向を遵守することは 完全にはできていないので、工夫した実装を紹介していこうと思います。 また、フロントエンドを円図で表現したら、という依存関係を整理した記事ですので、本家と対になるものではないので、ご注意願います。
図1 フロントエンドにおける円図
これは私の解釈ですが、 は名称からもわかるように、システム開発する上での 方法 に相当するものなので、比較的変更が入りやすいものだと考えます。そのような不安定なものをいたるところのコンポーネント類が参照していると、どんどん脆くなっていくでしょう。ということで、 との分離をメインの話題として、進行していきます。

1-2. 階層図

上図をもとに  に当てはめてみます。このシステムを構築するときの階層図/フォルダ構成の一部を以下に示します。円図の中心から解説するので、その順序と揃えた階層図ごとに解説していきます。

2章 モチベーション

以下のリリース履歴をご覧いただくと、早いスパンでバージョンアップされていることがわかります。
また、UI/UX については、流行り廃りやライブラリによる向き不向きもあると思うので、UI Libraryごと早々に置き換えることも想像できそうです。作り方がよろしくないと、当該のファイルだけではなく、そこを参照するファイルも修正する羽目になります。このような対応は コストがかかり、リスクを伴う傾向にあります。これらを最小限にして、ユーザ体験(UX)を素早く提供し、エンジニア体験(DevX)を向上させることが私のモチベーションです。
端的に、雑にいうと、しなくていい修正はしたくない ということですね。

3章 Element Parts

円図の中核を考えてみます。人それぞれ様々な解釈があると思いますが、私は UI Components、hooks、functions を割り当てました。
フロントエンドは、ある業務データをユーザにわかりやすく表現するための方法の一つなわけですが、画面に配置されるボタンやテキストエリア、またはデータ取得などのhooksや変換などのfunctions といった部品がフロントエンドを構成する重要な最小要素であると解釈できそうです。
参考までに、本家では、 が中核になっています。
図2 Clean Architecture

まず初めに、UI Components の実装を考えてみます。円図を再度確認すると、いきなり壁にぶち当たります。円図中心から UI Library への依存がNG であることがわかります。こういった逆依存を実装をしたい場合、本家では I/F を介することで、実現していました。(依存関係逆転の原則)
図3 UI Components から UI Library への参照NG
Spring Framework のような Annotation を使った依存性注入(DI)は手軽にできませんが、以下のような工夫をすれば、フロントエンドでもDIもどきを実現することができます。
/src/component/ui/_Button/MantineButton.tsx
/src/component/ui/_Button/index.ts
/src/component/ui/index.ts
実装した Button コンポーネントを呼び出すと、以下のようになります。
/src/component/page/SomeComponent.tsx
エイリアスとexportをうまく使うことで、Button コンポーネントがどのUI Libraryに依存したものかを隠蔽することが可能です。これにより、例えば Mui のButton に置き換えるとなっても、ButtonProps を受け取る MuiButton を実装し、
/src/component/ui/_Button/index.ts
とするだけで、呼び出し元の影響を最小限にすることができます。 の置き換えが容易にできる点が、本家と同じ恩恵を再現できています。
また、別の工夫ですが、TypeScriptでは、自身以外のファイルからアクセス可能とする場合、一律export する他ないので、 とすることで、同階層以外からのアクセスをNGとするルールにしておきます。こういったルールは、  を使うことで、静的検査が可能です。設定方法は省略しますが、こういったルール作りをすることで、意図しない逆依存を防ぐことが可能です。

hooks, functions も考え方は同じです。例えば、画面で入力された値をグローバルステートに保存することを考えます。詳しい実装までしませんが、以下の useGetStateやuseSaveState を使用したいライブラリに合わせて実装することで、例え、ライブラリの置き換え等があったとしても、中身を差し替えるだけで済むので、呼び出し元での修正を最小限にすることができます。
/src/hook/_householdSearch/useHouseholdSearchKey.ts
/src/hook/index.ts
/src/persistence/globalState/useGetState.ts
/src/persistence/globalState/useSaveState.ts
グルーバルステートを例にしましたが、cookie/session であっても、API Gatewayでも同じで、I/Fを揃えて、うまく隠蔽することで、呼び出し元の修正を最小限にできます。ただ、 クライアントレンダリングなのか、サーバサイドレンダリングなのかで、制約があるので留意が必要です。

最後に、Framework提供のコンポーネント等について言及して、この章を閉じます。
例えば、
  • next/link
  • next/image
  • next/navigation
など、Next.js が提供するUI Componentsやhooksも存在しています。とても便利ですが、バージョンアップにより、参照先や使用するhook、functionが変更された実績があるので、隠蔽しておくのが無難です。

4章 Aggregation Parts

サードパーティのライブラリを直接使用するのではなく、 を使用することで、円図を遵守することができます。
階層図には、Fetcher・Container・Presenter という単語を使用していますが、以下の責務に分割しています。
  • Fetcher .... サーバサイドで動作する。サーバサイドの範囲で可能な処理を実現する。Page Componentsの実態。
  • Container .. クライアントサイドで動作する。Stateの制御やデータの加工をする。Fetcherから呼び出される。
  • Presenter .. クライアントサイドで動作する。UIを決定する。Containerから呼び出される。
/src/component/page/index.ts
/src/component/page/_Balance/index.ts
/src/component/page/_Balance/BalanceFetcher.tsx
/src/component/page/_Balance/BalanceContainer.tsx
/src/component/page/_Balance/BalancePresenter.tsx
Fetcherについては、記載してないですが、以下で説明しているので、よければご覧ください。
!
tailwindcss を採用しているプロジェクトも多くあると思いますが、こちらは、 に属します。そのため、依存関係を遵守するため、UI Components を定義して、それらをPresenterで呼び出すようなことをすると、手間がかかりすぎて、DevXの低下に繋がりかねません。私たちが推進しているプロジェクトでは、Presenterに直接記述し、逆依存な状態を許容しています。

5章 Method Parts

 の中でも、Framework / App Router について言及したいと思います。以下は、App Routerで特別な意味を持っています。
  • page.tsx
  • layout.tsx
  • template.tsx
  • error.tsx
これらのファイルは、ルーティングや、ページのレイアウト決定、の呼び出しを責務としています。page.tsxから呼び出されるコンポーネント類のフォルダと干渉しないようにすることで、関心の分離を実現することができます。
Frameworkは、あくまでページを表示する上でのサポート的な役割なので、ページの構成などは実装しないように工夫しています。もう一つは、 のようなパラメータの扱いですが、これ自体もFrameworkの機構なので、page.tsx で完結させるのもポイントです。
/src/app/dashboard/layout
/src/app/dashboard/@balance/page
App Routerには、 という機構があるのですが、これは密結合になり、改修の妨げになることが想像されるので、私はお勧めしないです。詳しくは、過去の記事でも取り上げています。

終章 まとめ

本家  にならって、関心の分離を見極めることで、外部ライブラリの置き換えやバージョンアップに強いシステムにすることができそうです。さらには、より汎用的なコンポーネントが作れていれば、別のシステム開発へ使い回すこともできます。
ただ、それなりに学習コストのかかるものなので、この理論を採用すべきだとか微塵も思っていないので、開発メンバ間で扱いやすいものを採択すればいいかと思います。
今回、提案したアーキテクチャはあくまでも現時点の私の理解度なので、よりよりものがあれば、コメントいただけると幸いです!最後まで、ご覧いただきありがとうございました。