🤔

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

2023/12/05に公開2

今回構築するシステムの概要は以下です。そこまで重要ではないので、さらっと読み流してよいです。

  • 家計簿アプリ / 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章 モチベーション

以下のリリース履歴をご覧いただくと、早いスパンでバージョンアップされていることがわかります。

https://github.com/vercel/next.js/releases

https://github.com/mantinedev/mantine/releases

https://github.com/mui/mui-x/releases

また、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もどきを実現することができます。

/src/component/ui/_Button/MantineButton.tsx
import { ButtonProps } from '.';
import { Button } from '@mantine/core';

export const MantineButton = ({ label, onClick }: ButtonProps) => 
    <Button onClick={onClick}>{label}</Button>;
/src/component/ui/_Button/index.ts
// ↓ Button というI/FにMantineButtonを注入していると見なせる
export { MantineButton as Button } from './MantineButton'; 

export type ButtonProps = {
    label: string;
    onClick: () => void;
};
/src/component/ui/index.ts
export * from './_Button';

実装した Button コンポーネントを呼び出すと、以下のようになります。

/src/component/page/SomeComponent.tsx
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 を実装し、

/src/component/ui/_Button/index.ts
- export { MantineButton as Button } from './MantineButton';
+ export { MuiButton as Button } from './MuiButton';

...

とするだけで、呼び出し元の影響を最小限にすることができます。Method Parts の置き換えが容易にできる点が、本家と同じ恩恵を再現できています。

また、別の工夫ですが、TypeScriptでは、自身以外のファイルからアクセス可能とする場合、一律export する他ないので、_フォルダ名 とすることで、同階層以外からのアクセスをNGとするルールにしておきます。こういったルールは、 ESlint を使うことで、静的検査が可能です。設定方法は省略しますが、こういったルール作りをすることで、意図しない逆依存を防ぐことが可能です。

https://eslint.org


hooks, functions も考え方は同じです。例えば、画面で入力された値をグローバルステートに保存することを考えます。詳しい実装までしませんが、以下の useGetStateやuseSaveState を使用したいライブラリに合わせて実装することで、例え、ライブラリの置き換え等があったとしても、中身を差し替えるだけで済むので、呼び出し元での修正を最小限にすることができます。

/src/hook/_householdSearch/useHouseholdSearchKey.ts
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
    }
}
/src/hook/index.ts
export * from './_householdSearch';
/src/persistence/globalState/useGetState.ts
import xxx from './jotai'; // とかRecoilとか各仕様に合わせる

export const <T,> useGetState = (key: string) => 
    xxx.get(key) as T; // Recoil や Jotai 等の各仕様にあわせて実装する。
/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が変更された実績があるので、隠蔽しておくのが無難です。

- 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から呼び出される。
/src/component/page/index.ts
export * from './_Balance';
/src/component/page/_Balance/index.ts
export { BalanceFetcher as Balance } from './BalanceFetcher'
/src/component/page/_Balance/BalanceFetcher.tsx
import { BalanceContainer } from './BalanceContainer';

export const BalanceFetcher = async ({baseDate}: {baseDate: Date}) => {
    const { user } = await fetchUser();

    return (
        <BalanceContainer
            baseDate={baseDate}
            user={user}
        />
    )
}
/src/component/page/_Balance/BalanceContainer.tsx
'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}        
        />
    );
}
/src/component/page/_Balance/BalancePresenter.tsx
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については、記載してないですが、以下で説明しているので、よければご覧ください。

https://zenn.dev/ficilcom/articles/app_router_design_pattern

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 で完結させるのもポイントです。

/src/app/dashboard/layout
import { Dashboard } from '@/component/page';

export default Dashboard;
/src/app/dashboard/@balance/page
import { Balance } from '@/component/page';

const Page = ({
    params: { date }
}: {
    params: { date: string }
}) => <Balance date={new Date(date)}/>;

export default Page;

App Routerには、Private Folders という機構があるのですが、これは密結合になり、改修の妨げになることが想像されるので、私はお勧めしないです。詳しくは、過去の記事でも取り上げています。

https://zenn.dev/ficilcom/articles/app_router_private_folder#3.-private-folders

終章 まとめ

本家 Clean Architecture にならって、関心の分離を見極めることで、外部ライブラリの置き換えやバージョンアップに強いシステムにすることができそうです。さらには、より汎用的なコンポーネントが作れていれば、別のシステム開発へ使い回すこともできます。
ただ、それなりに学習コストのかかるものなので、この理論を採用すべきだとか微塵も思っていないので、開発メンバ間で扱いやすいものを採択すればいいかと思います。

今回、提案したアーキテクチャはあくまでも現時点の私の理解度なので、よりよりものがあれば、コメントいただけると幸いです!最後まで、ご覧いただきありがとうございました。

GitHubで編集を提案
フィシルコム

Discussion

白鳥白鳥

私は「Reactで依存性注入(DI)をするにはContextがいいぞ」という考えを持っています。
Presenterがクライアントサイドでの実行ならContextを使ったDIがいいかもしれません。
メリットとしてはProviderのところでMantineButtonの型検査が入ることと、実装を変更するときにアーキテクチャ図の中心のUIComponentのコードを一切いじる必要がないところです。
ご参考になれば

/src/component/ui/Button.tsx
import { FC, createContext, useContext } from "react"

export type ButtonProps = {
    label: string,
    onClick: () => void
}

const ButtonContext = createContext<FC<ButtonProps>>(null as never)

export const ButtonProvider = ButtonContext.Provider

export function useButton() {
    return useContext(ButtonContext)
}
/src/component/ui/Button/MantineButton.tsx

import { ButtonProps } from '@/components/ui/Button';
import { Button } from '@mantine/core';
import { FC } from 'react';

export const MantineButton:FC<ButtonProps> = ({ label, onClick }) =>
    <Button onClick={onClick}>{label}</Button>;
/src/component/Providers.tsx
"use client"
import {PropsWithChildren} from "react";
import {ButtonProvider} from "@/component/ui/Button";
import {MantineButton} from "@/component/ui/Button/MantineButton";

export const Providers: FC<PropsWithChildren> = ({ children }) => {
  return <ButtonProvider value={MantineButton}>
    {children}
  </ButtonProvider>;
}
/src/component/page/SomeComponent.tsx
"use client"
export { useButton } from '@/component/ui/Button';

export const SomeComponent () => {
    const Button = useButton();

    return (
        <div>
            <Button label={"あるボタン"} onClick={() => alert("ボタン押した")}/>
        </div>
    );
}
Ryo KageyamaRyo Kageyama

コメントありがとうございます

実装を変更するときにアーキテクチャ図の中心のUIComponentのコードを一切いじる必要がないところです。

確かにそうですね。
この記事を書くうえで、そこが誤魔化していたところなので、助かります。
参考にさせていただきます!