Next.js/App Router を CleanArchitecture風に構築してみた
今回構築するシステムの概要は以下です。そこまで重要ではないので、さらっと読み流してよいです。
- 家計簿アプリ / household-manager の構築
- Frameworkは、Next.js/App Router を採用する
- dashboard ページに、残高と登録履歴が表示される
- UI Library は、
Mantine
およびMaterial-UI
を使用する
1章 結論
導入を書いていると、長くなるので、結論からブレークダウンしようと思います。
1-1. アーキテクチャ図
詳細は後述しますが、本家 Clean Architecture
と得られる恩恵は同じで フロントエンドを構成する要素同士の 関心の分離 をすることで、外部ライブラリの置き換え・バージョンアップを容易に対応できるようにする ことを狙っていきたいと思います。
図1 フロントエンドにおける円図
これは私の解釈ですが、Method Parts
は名称からもわかるように、システム開発する上での 方法 に相当するものなので、比較的変更が入りやすいものだと考えます。そのような不安定なものをいたるところのコンポーネント類が参照していると、どんどん脆くなっていくでしょう。ということで、Method Parts
との分離をメインの話題として、進行していきます。
1-2. 階層図
上図をもとに household-manager
に当てはめてみます。このシステムを構築するときの階層図/フォルダ構成の一部を以下に示します。円図の中心から解説するので、その順序と揃えた階層図ごとに解説していきます。
household-manager
|- src
|- component
|- ui ====> 「Element Parts」3章で解説します
|- index.ts .................... 使用可能な ui を宣言するファイル
|- _Button
|- index.tx ................ 使用する Button を宣言するファイル
|- MantineButton.tsx ....... Button の実態、Buttonについては Mantineを採択
|- _DateInput
|- index.tx ................ 使用する DateInput を宣言するファイル
|- MuiDateInput.tsx ........ DateInput の実態、DateInputについては、Material-UI を採択
|- ...
|- page ====> 「Aggregation Parts」4章で解説します
|- index.ts ............... 使用可能な page を宣言するファイル
|- _Dashboard
|- index.ts ........... ダッシュボードページ を構成するファイル
|- Frame.tsx
|- _Balance
|- index.ts ........... 残高ページ を構成するファイル
|- Fetcher.tsx
|- Container.tsx
|- Presenter.tsx
|- ...
|- app ====> 「Method Parts」5章で解説します
|- page.tsx ........... "/" ページへのルーティング及びpageコンポの呼び出し
|- layout.tsx ......... "/" 配下のページ全体に対する共通レイアウト
|- error.tsx .......... "/" 配下のページ全体に対する共通例外ハンドリング(任意)
|- dashboard
|- layout.tsx ..... "/dashboard" 配下ページ全体に対する共通レイアウト
|- @balance
|- page.tsx ... "/dashboard" の balance 部分のルーティング及びpageコンポの呼び出し
|- @history
|- page.tsx ... "/dashboard" の history 部分のルーティング及びpageコンポの呼び出し
2章 モチベーション
以下のリリース履歴をご覧いただくと、早いスパンでバージョンアップされていることがわかります。
また、UI/UX については、流行り廃りやライブラリによる向き不向きもあると思うので、UI Libraryごと早々に置き換えることも想像できそうです。作り方がよろしくないと、当該のファイルだけではなく、そこを参照するファイルも修正する羽目になります。このような対応は コストがかかり、リスクを伴う傾向にあります。これらを最小限にして、ユーザ体験(UX)を素早く提供し、エンジニア体験(DevX)を向上させることが私のモチベーションです。
端的に、雑にいうと、しなくていい修正はしたくない ということですね。
3章 Element Parts
円図の中核を考えてみます。人それぞれ様々な解釈があると思いますが、私は UI Components、hooks、functions を割り当てました。
フロントエンドは、ある業務データをユーザにわかりやすく表現するための方法の一つなわけですが、画面に配置されるボタンやテキストエリア、またはデータ取得などのhooksや変換などのfunctions といった部品がフロントエンドを構成する重要な最小要素であると解釈できそうです。
参考までに、本家では、Enterprise Business Rules
が中核になっています。
図2 Clean Architecture
まず初めに、UI Components の実装を考えてみます。円図を再度確認すると、いきなり壁にぶち当たります。円図中心から UI Library への依存がNG であることがわかります。こういった逆依存を実装をしたい場合、本家では I/F を介することで、実現していました。(依存関係逆転の原則)
図3 UI Components から UI Library への参照NG
Spring Framework のような Annotation を使った依存性注入(DI)は手軽にできませんが、以下のような工夫をすれば、フロントエンドでもDIもどきを実現することができます。
import { ButtonProps } from '.';
import { Button } from '@mantine/core';
export const MantineButton = ({ label, onClick }: ButtonProps) =>
<Button onClick={onClick}>{label}</Button>;
// ↓ Button というI/FにMantineButtonを注入していると見なせる
export { MantineButton as Button } from './MantineButton';
export type ButtonProps = {
label: string;
onClick: () => void;
};
export * from './_Button';
実装した Button コンポーネントを呼び出すと、以下のようになります。
export { Button } from '@/component/ui';
export const SomeComponent () => {
...
return (
<div>
<Button label={"あるボタン"} onClick={() => alert("ボタン押した")}>
<div>...</div>
</div>
);
}
エイリアスとexportをうまく使うことで、Button コンポーネントがどのUI Libraryに依存したものかを隠蔽することが可能です。これにより、例えば Mui のButton に置き換えるとなっても、ButtonProps を受け取る MuiButton を実装し、
- export { MantineButton as Button } from './MantineButton';
+ export { MuiButton as Button } from './MuiButton';
...
とするだけで、呼び出し元の影響を最小限にすることができます。Method Parts
の置き換えが容易にできる点が、本家と同じ恩恵を再現できています。
また、別の工夫ですが、TypeScriptでは、自身以外のファイルからアクセス可能とする場合、一律export する他ないので、_フォルダ名
とすることで、同階層以外からのアクセスをNGとするルールにしておきます。こういったルールは、 ESlint
を使うことで、静的検査が可能です。設定方法は省略しますが、こういったルール作りをすることで、意図しない逆依存を防ぐことが可能です。
hooks, functions も考え方は同じです。例えば、画面で入力された値をグローバルステートに保存することを考えます。詳しい実装までしませんが、以下の useGetStateやuseSaveState を使用したいライブラリに合わせて実装することで、例え、ライブラリの置き換え等があったとしても、中身を差し替えるだけで済むので、呼び出し元での修正を最小限にすることができます。
import { useGetState, useSaveState } from '@/persistence/globalState';
export const useHouseholdSearchKey = () => {
const searchKey = useGetState<string[]>('householdSearchKey');
const { save } = useSaveState<string[]>('householdSearchKey');
const saveSearchKey = (searchKey: string[]) => save(searchKey);
return {
searchKey,
saveSearchKey
}
}
export * from './_householdSearch';
import xxx from './jotai'; // とかRecoilとか各仕様に合わせる
export const <T,> useGetState = (key: string) =>
xxx.get(key) as T; // Recoil や Jotai 等の各仕様にあわせて実装する。
省略
グルーバルステートを例にしましたが、cookie/session であっても、API Gatewayでも同じで、I/Fを揃えて、うまく隠蔽することで、呼び出し元の修正を最小限にできます。ただ、 クライアントレンダリングなのか、サーバサイドレンダリングなのかで、制約があるので留意が必要です。
最後に、Framework提供のコンポーネント等について言及して、この章を閉じます。
例えば、
- next/link
- next/image
- next/navigation
など、Next.js が提供するUI Componentsやhooksも存在しています。とても便利ですが、バージョンアップにより、参照先や使用するhook、functionが変更された実績があるので、隠蔽しておくのが無難です。
- import { router } from 'next/router'; // Next12
+ import { router } from 'next/navigation'; // Next13
4章 Aggregation Parts
サードパーティのライブラリを直接使用するのではなく、Element Parts
を使用することで、円図を遵守することができます。
階層図には、Fetcher・Container・Presenter という単語を使用していますが、以下の責務に分割しています。
- Fetcher .... サーバサイドで動作する。サーバサイドの範囲で可能な処理を実現する。Page Componentsの実態。
- Container .. クライアントサイドで動作する。Stateの制御やデータの加工をする。Fetcherから呼び出される。
- Presenter .. クライアントサイドで動作する。UIを決定する。Containerから呼び出される。
export * from './_Balance';
export { BalanceFetcher as Balance } from './BalanceFetcher'
import { BalanceContainer } from './BalanceContainer';
export const BalanceFetcher = async ({baseDate}: {baseDate: Date}) => {
const { user } = await fetchUser();
return (
<BalanceContainer
baseDate={baseDate}
user={user}
/>
)
}
'use client';
export { useGetBalance } from '@/hook/balance';
export { User } from '@/type'
import { BalancePresenter } from './BalancePresenter';
export const BalanceContainer ({
baseDate,
user,
}: {
baseDate: Date;
user: User;
}) => {
const { data } = useGetBalance({baseDate});
const searchHandler = () => {
...
};
return (
<BalancePresenter
searchHandler={searchHandler}
userName={user.name}
balance={data.balance}
/>
);
}
export { Button } from '@/component/ui';
export const BalancePresenter ({
searchHandler,
userName,
balance,
}: {
searchHandler: () => void;
userName: string;
balance: number;
}) => (
<div>
<Button label={"検索"} onClick={searchHandler}>
<div>
<div>{userName} さんの残高:</div>
<div>{balance.toLocaleString()} 円</div>
</div>
</div>
);
Fetcherについては、記載してないですが、以下で説明しているので、よければご覧ください。
5章 Method Parts
Method Parts
の中でも、Framework / App Router について言及したいと思います。以下は、App Routerで特別な意味を持っています。
- page.tsx
- layout.tsx
- template.tsx
- error.tsx
これらのファイルは、ルーティングや、ページのレイアウト決定、Aggregation Parts
の呼び出しを責務としています。page.tsxから呼び出されるコンポーネント類のフォルダと干渉しないようにすることで、関心の分離を実現することができます。
Frameworkは、あくまでページを表示する上でのサポート的な役割なので、ページの構成などは実装しないように工夫しています。もう一つは、params.date
のようなパラメータの扱いですが、これ自体もFrameworkの機構なので、page.tsx で完結させるのもポイントです。
import { Dashboard } from '@/component/page';
export default Dashboard;
import { Balance } from '@/component/page';
const Page = ({
params: { date }
}: {
params: { date: string }
}) => <Balance date={new Date(date)}/>;
export default Page;
App Routerには、Private Folders
という機構があるのですが、これは密結合になり、改修の妨げになることが想像されるので、私はお勧めしないです。詳しくは、過去の記事でも取り上げています。
終章 まとめ
本家 Clean Architecture
にならって、関心の分離を見極めることで、外部ライブラリの置き換えやバージョンアップに強いシステムにすることができそうです。さらには、より汎用的なコンポーネントが作れていれば、別のシステム開発へ使い回すこともできます。
ただ、それなりに学習コストのかかるものなので、この理論を採用すべきだとか微塵も思っていないので、開発メンバ間で扱いやすいものを採択すればいいかと思います。
今回、提案したアーキテクチャはあくまでも現時点の私の理解度なので、よりよりものがあれば、コメントいただけると幸いです!最後まで、ご覧いただきありがとうございました。
フィシルコムのテックブログです。マーケティングSaaSを開発しています。 マイクロサービス・AWS・NextJS・Golang・GraphQLに関する発信が多めです。 カジュアル面談はこちら(ficilcom.notion.site/bbceed45c3e8471691ee4076250cd4b1)から
Discussion
私は「Reactで依存性注入(DI)をするにはContextがいいぞ」という考えを持っています。
Presenterがクライアントサイドでの実行ならContextを使ったDIがいいかもしれません。
メリットとしてはProviderのところでMantineButtonの型検査が入ることと、実装を変更するときにアーキテクチャ図の中心のUIComponentのコードを一切いじる必要がないところです。
ご参考になれば
コメントありがとうございます
確かにそうですね。
この記事を書くうえで、そこが誤魔化していたところなので、助かります。
参考にさせていただきます!