🔥

フルスタックNext.jsにおけるPackage by Featureを取り入れたディレクトリ構成案

2024/12/06に公開

最近、アプリケーションコードを機能ごとにまとめて配置する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/      : 汎用的なユーティリティ

この中でも特に今回の肝となるappfeaturesディレクトリについて詳しく見ていきます。

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.tsxlayout.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側に記述します。

features/post/api.ts
export async function getPosts() {
  // ...
}

export async function createPost(post: Post) {
  // ...
}

コンポーネント

コンポーネントは、データ取得などのサーバーサイド処理を担うContainerコンポーネント (Server Component) と、データの表示を担うPresentationalコンポーネント (Client Component) に分離します。

これは従来のContainer/Presentationalパターンとは異なる、React Server Components時代のContainer/Presentationalパターンとされているものです。

https://zenn.dev/akfm/books/nextjs-basic-principle/viewer/part_2_container_presentational_pattern

Presentationalコンポーネント (Client Component)

今回の例では、投稿一覧の表示と投稿作成フォームをPresentationalコンポーネントに実装します。

Containerから投稿一覧 (posts) と投稿を作成するServer Action (createPostAction) を受け取れるようpropsを定義します。

features/post/components/posts.tsx
'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をファイル内に作成することで対応しています。

features/post/components/posts.container.tsx
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で呼び出します。

app/posts/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-accesseslint-plugin-boundariesなどがありますが、今回のニーズには一致しなかったためeslint-plugin-import-scopeというESLintプラグインを新たに作成しました。

https://github.com/seno-dev/eslint-plugin-import-scope

https://x.com/seno_dev/status/1861641567687364954

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/_*は、featuresappe2e内でのみインポートできる

まとめ

Next.jsを使用したフルスタック構成は、特に小〜中規模の開発における最適な選択肢の一つであると考えています。

本記事の設計はまだ実験段階のため、より良い構成案などの情報もお待ちしております。

参考になった記事

https://zenn.dev/manalink_dev/articles/bulletproof-react-is-best-architecture

https://zenn.dev/pandanoir/articles/d74d317f2b3caf

https://scrapbox.io/mrsekut-p/Package_by_Feature

https://speakerdeck.com/kaonavi/implementing-package-by-feature-in-kaonavi

https://techblog.technology-doctor.com/entry/2024/09/12/172551

https://qiita.com/honey32/items/dbf3c5a5a71636374567

https://zenn.dev/ficilcom/articles/091abe948f44fb

https://zenn.dev/akfm/books/nextjs-basic-principle

https://quramy.medium.com/react-server-component-のテストと-container-presentation-separation-7da455d66576

脚注
  1. https://zenn.dev/moozaru/articles/b0ef001e20baaf ↩︎

Discussion