フルスタックNext.jsにおけるPackage by Featureを取り入れたディレクトリ構成案
最近、アプリケーションコードを機能ごとにまとめて配置するPackage by Featureが注目されてきていますが、その例はフロントエンド・バックエンドいずれかのみを取り扱ったものが多く、Next.jsですべて完結するようなフルスタック開発で取り入れるにはどのようにすべきか知見が得られていませんでした。
今回、小規模なWebアプリケーションを作るにあたって試行錯誤した結果、個人的なベストプラクティスを見つけたので共有します。
前提条件
- アプリケーション全体がNext.jsで完結する
- データの取得はServer Components、更新はServer Actionsで行う
- 掲示板サイトを想定し、単一ページ内で投稿一覧の表示と投稿の作成ができる
全体のディレクトリ構成
大枠のディレクトリ構成はこのようにBulletproof Reactを参考にした形としました。
- app/ : Next.jsのappディレクトリ
- components/
- layouts/ : レイアウトコンポーネント
- ui/ : 汎用的なUIコンポーネント (ボタンなど)
- e2e/ : E2Eテスト
- features/ : 各機能に関するコード
- hooks/ : 汎用的なReact Hooks
- lib/ : ライブラリのラッパーなど
- public/ : Next.jsのpublicディレクトリ
- testing/ : テストで使うセットアップファイルやユーティリティ
- utils/ : 汎用的なユーティリティ
この中でも特に今回の肝となるapp、featuresディレクトリについて詳しく見ていきます。
app、features内のディレクトリ構成
App RouterにはPrivate Foldersという機能があり、appディレクトリ内では_
から始まるディレクトリはルーティングから除外されます。この機能を使うと、
- app/
- posts/
- page.tsx
- _components/
- Post.tsx
のように、特定のRoute (/posts
) 配下でしか使わないコンポーネント (Post.tsx
) をそのRouteディレクトリ内に配置できます。
以前このパターンを採用したこともありましたが、appディレクトリが肥大化しやすく、ファイルの場所も把握しづらいという問題がありました。そのため今回はPrivate Foldersを使用せず、app内にはNext.jsで定められたファイルのみを配置する形としました。各機能に関するコードはfeaturesディレクトリに集約し、後述するルールに従ってapp内のpage.tsx
やlayout.tsx
から呼び出します。
結果としてはこのような構成になりました。
- app/
- posts/
- page.tsx : ページ
- features/
- post/
- api.ts : サーバー上での各ユースケースの処理
- components/
- posts.container.tsx : Containerコンポーネント (Server Component)
- posts.tsx : Presentationalコンポーネント (Client Component)
API
サーバー上での各ユースケースの処理 (DBへのアクセスなどを含む) は各feature直下のapi.ts
に記述します。
Server Actionsではなく単純なNode.js上の関数として定義し、後述するContainerから呼び出します。認証処理がある場合、こちらには書かずContainer側に記述します。
export async function getPosts() {
// ...
}
export async function createPost(post: Post) {
// ...
}
コンポーネント
コンポーネントは、データ取得などのサーバーサイド処理を担うContainerコンポーネント (Server Component) と、データの表示を担うPresentationalコンポーネント (Client Component) に分離します。
これは従来のContainer/Presentationalパターンとは異なる、React Server Components時代のContainer/Presentationalパターンとされているものです。
Presentationalコンポーネント (Client Component)
今回の例では、投稿一覧の表示と投稿作成フォームをPresentationalコンポーネントに実装します。
Containerから投稿一覧 (posts
) と投稿を作成するServer Action (createPostAction
) を受け取れるようpropsを定義します。
'use client'
interface Props {
posts: Post[]
createPostAction: (post: Post) => Promise<void>
}
export function Posts({ posts, createPostAction }: Props) {
const [inputText, setInputText] = useState('')
return (
<div>
<ul>
{posts.map(({ id, text }) => (
<li key={id}>{text}</li>
))}
</ul>
<input
value={inputText}
onChange={({ target }) => setInputText(target.value)}
/>
<button onClick={() => createPostAction({ text: inputText })}>
{'Send'}
</button>
</div>
)
}
'use client'
をつけたClient ComponentはServer Componentとの境界となり得るため、propsの型に関数を含めるとNext.jsのCustom TypeScript Pluginによってwarningが発生しますが、例外としてServer Actionsは受け取ることができ、それを明示するためにprop名の末尾をAction
にするとwarningを回避できます。
Containerコンポーネント (Server Component)
Containerコンポーネントは、api.ts
で定義したユースケースを呼び出しPresentationalコンポーネントに受け渡す役割を担います。
投稿一覧に関してはgetPosts()
で取得してPosts
コンポーネントに渡すだけなので簡単ですが、ここでポイントとなるのが投稿を作成する処理の受け渡し方です。createPost
はServer Actionではないので直接Posts
コンポーネントに渡すことはできません。
そのため、以下のようにcreatePost
をラップするServer Actionをファイル内に作成することで対応しています。
async function createPostAction(post: Post) {
'use server'
await createPost(post)
}
export async function PostsContainer() {
const posts = await getPosts()
return <Posts posts={posts} createPostAction={createPostAction} />
}
ページ
最後に上記のContainerコンポーネントをpage.tsx
で呼び出します。
export default async function Page() {
return <PostsContainer />
}
以上が今回のディレクトリ構成となります。
メリット
各ファイルの役割をまとめると以下のようになります。
- API (
api.ts
) - 各処理を単純なNode.js上の関数として記述 - Presentationalコンポーネント - データの表示、イベントの発火
- Containerコンポーネント - APIとPresentationalコンポーネントの橋渡し、認証処理
- ページ - Containerコンポーネントの呼び出し
この構成のメリットとして、以下の点が挙げられます。
-
api.ts
ではその関数がどのように呼び出されるか (Server Componentsなのか、Server Actionsなのか、あるいはRoute Handlerなのか) を意識せずに記述できる - ファイル単位の
'use server'
をつけないため、Server Actionsが意図せず外部に露出する[1]のを防げる - Containerにはドメインロジックを書かないので、ユニットテストはAPIとPresentationalコンポーネントのみでよい (全体を通しての動作確認はE2Eテストで行う)
ESLintでディレクトリ構成を守る
今回のようなディレクトリ構成では、依存関係をルールで守ることが重要になってきます。
特定のファイルや変数をインポートできるディレクトリを制限するESLintプラグインにはeslint-plugin-import-accessやeslint-plugin-boundariesなどがありますが、今回のニーズには一致しなかったためeslint-plugin-import-scopeというESLintプラグインを新たに作成しました。
eslint-plugin-import-scopeを使うと、以下のようなルールを記述するだけで、指定したディレクトリやファイルのインポートを特定のディレクトリ内でのみ許可できます。
[
{ dir: 'features/*', scope: '.' },
{ file: 'features/*/api.ts', scope: './components' },
{ file: 'features/*/components/*.container.tsx', scope: 'app' },
{ dir: 'features/_*', scope: ['features', 'app', 'e2e'] },
]
上記ルールを指定した場合、以下のように機能します。
- ディレクトリ
features/*
内のファイルは、features/*
自身の内部でのみインポートできる (他のfeatureからはインポートできない) - ファイル
features/*/api.ts
は、features/*
自身の中にあるcomponents
内でのみインポートできる - ファイル
features/*/components/*.container.tsx
は、app
内でのみインポートできる - ディレクトリ
features/_*
は、features
、app
、e2e
内でのみインポートできる
まとめ
Next.jsを使用したフルスタック構成は、特に小〜中規模の開発における最適な選択肢の一つであると考えています。
本記事の設計はまだ実験段階のため、より良い構成案などの情報もお待ちしております。
参考になった記事
Discussion