Next.js featuresディレクトリ構成で実現するスケーラブルなフロントエンド設計

こんにちは。フロントエンドエンジニアの禹です。 弊社で開発中の商品データを管理するWebアプリケーションに選定したfeaturesディレクトリ構成について解説し、自分が思ったメリットやデメリット、そして実装のポイント事例を紹介します。

対象アプリケーションの概要

技術スタック:

  • Next.js 15.3.1
  • React 19
  • TypeScript
  • Material-UI
  • NextAuth.js

主な機能:

  • アイテム管理
  • フィルタリング
  • テーブル表示
  • ページネーション
  • データ処理

featuresディレクトリ構成

featuresディレクトリ構成は、機能(feature)単位でファイルを配置するディレクトリ構成となります。

src/
├── features/                   # 機能別ディレクトリ
│   ├── alert/                  # アラート機能
│   ├── fileProcessing/         # ファイル処理機能
│   ├── filter/                 # フィルタリング機能
│   │   ├── components/
│   │   │   └── FilterModal.tsx
│   │   ├── function/
│   │   ├── hooks/
│   │   ├── types/
│   │   ├── action/
│   │   └── stories/
│   ├── pagination/             # ページネーション機能
│   └── table/                  # テーブル機能
├── hooks/                      # 共通hooks
├── lib/                        # 共通ライブラリ
└── types/                      # 型定義

featuresディレクトリのメリット

1. メンテナンスがしやすい

具体例:
機能の修正が必要な場合、ロジックが集約されているため直接修正でき、メンテナンスがしやすいと感じています。

src/features/filter/
├── components/FilterModal.tsx  ← ここを直接修正
├── function/mergeFilterParams.ts
└── stories/FilterModal.stories.tsx

メリット:

  • 機能に関連するすべてのファイルが同一ディレクトリ配下に集約
  • 修正範囲の特定がしやすい
  • 新メンバーでも直感的に理解ができる

2. 独立性の高い機能開発

各機能が独立したディレクトリ構造をもつため機能に必要なすべての要素が一箇所に集約されます。

// features/table/components/ItemListTable/index.tsx
// テーブル機能に特化したコンポーネント
export default function ItemListTable({
  searchParams,
  rows,
  columns,
}: {
  searchParams: Record<string, string>;
  rows: ItemList[];
  columns: Column<ItemList>[];
}) {
  // テーブル特有のロジックをここに集約
  const handleChange = async ({
    property,
    order,
  }: {
    property: string;
    order: "asc" | "desc";
  }) => {
    // ソート処理
  };

  // テーブル特有の選択処理
  const handleSelect = (selected: ItemList[]) => {
    // 選択処理
  };
  return (
    <Table ...{}/>
  );
}

この構造の利点:

  • 機能ごとの責任境界が明確: 関連のロジックはすべて集約。
  • 並行開発がしやすい: 他の機能(filter、alert等)の開発と独立して作業が可能。
  • 保守性の向上: 機能変更時の影響範囲が限定される。

3. スケーラブルなチーム開発

大規模プロジェクトでのチーム分割例: 各チームが独立して作業でき、コンフリクトのリスクが減少できそうです。

チームA:features/table/     (テーブル機能担当)
チームB:features/filter/    (フィルタ機能担当)
チームC:features/alert/     (アラート機能担当)

featuresディレクトリのデメリット

1. コンポーネントの重複リスク

問題例:
機能ごとにディレクトリを分割すると、各機能で似たようなコンポーネントを独自に作成してしまう可能性があります。

features/filter/components/CommonButton.tsx
features/table/components/CommonButton.tsx  ← 類似コンポーネント
features/alert/components/CommonButton.tsx  ← さらに類似

問題例のように、同じような見た目や機能をもつボタンコンポーネントが各機能で重複して作成され、メンテナンスコストが増える問題が発生しそうです。

対策:
この問題に対する対策として、共通componentディレクトリで再利用可能なコンポーネントを管理し、見た目重視のコンポーネントと機能重視のコンポーネントを明確に分離します。

src/
├── features/                     # 機能別ディレクトリ
├── component/                    # 再利用が可能なコンポーネントディレクトリ
│   └── elements/
│          └── CommonButton
│                 ├── index.tsx
│                 ├── props.tsx
│                 └── index.stories.tsx

2. 共通ロジックの発見困難性

機能間で共通化できるロジックが見つけにくい場合があります。

対策:
共通管理するロジックは別管理をするようにします。

src/
├── features/                     # 機能固有
│     └── table/
│           └── serverAction.ts   # 固有コンポーネントでデータ取得ロジック
├── lib/                          # 共通ライブラリ
│   ├── buildUrlParams.ts         # URL構築
│   └── createParamObject.ts      # パラメータ処理

3. 初期学習コストの存在

次の点で学習コストがかかりました。

  • 機能分割の判断基準習得
  • 共通化のタイミング判断

このプロジェクトの実装方向性の結論

1. 機能の切り方設定

プロジェクト開始当初は画面単位でディレクトリを分けていましたが、ページ間で同じ機能が必要になるケースが見られました。 ページを機能と捉えると単位が大きくなりすぎて、このような問題が発生します。 逆に機能の単位が細かすぎるとディレクトリが煩雑になり、featuresディレクトリのメリットである集約性が失われてしまいます。

❌ 画面単位切り方
features/
├── home/
├── salesInfo/
└── dashboard/

❌ 細すぎる切り方
features/
├── buttonClick/
├── modal/
└── accordion/

⭐ 適切な切り方
features/
├── auth/            # 認証全般
├── filter/          # 絞り込み全般
└── fileProcessing/  # ファイル処理機能全般

チーム内で決めた適切な切り方の判断基準:

  • ビジネスロジック単位: 意味のある機能単位で分割
  • 独立性: 他の機能に依存しすぎない適度な独立性を保つ
  • チーム分担: 1つのチームが担当できる適切なサイズ

2. 見た目重視と機能重視の役割を明確にする

// 機能固有(features/filter/components内)
const FilterSpecificButton = ({ onApplyFilter }: Props) => {
  return <CommonButton onClick={onApplyFilter}>フィルタ適用</CommonButton>;
};

// 見た目重視共通コンポーネント(component/elements内)
const CommonButton = ({ onClick, children, variant = 'primary' }: Props) => {
  return (
    <button className={`btn btn-${variant}`} onClick={onClick}>
      {children}
    </button>
  );
};

3. 共通ロジック別管理(データ取得例)

機能間で共通化できるロジックを適切に抽出し、再利用しやすい形で管理します。

// src/lib/buildUrlParams.ts(共通ライブラリ)
export function buildUrlParams(params: Record<string, string>): string {
  return Object.entries(params)
    .filter(([_, value]) => value)
    .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
    .join('&');
}

// features/table/action/getItemListData.ts(内部フェッチ処理)
export const getItemListData = async (
  params?: string
): Promise<{ columns: Column<ItemList>[]; rows: ItemList[] }> => {
  'use server';
  const response = await fetch(`/api/item?${params}`)
    .then((res) => res.json())
    .catch((error) => console.log(error));
  return response.data;
};

使用側:

// features/table/components/ItemList/index.tsx
export default function ItemList() {
  const queryString = buildUrlParams(Object.fromEntries(searchParams));
  const { columns, rows } = getItemListData(queryString);

  return <Table rows={rows} columns={item} />;
}

4. TypeScript型定義の管理

// types/global.ts(グローバル型)
export type BaseEntity = {
  id: string;
  createdAt: Date;
}

// features/table/types.ts(機能固有型)
export type ItemList & BaseEntity = {
  name: string;
  price: number;
  category: string;
}

まとめ

featuresディレクトリ構成は、特に次の場面で威力を発揮できそうです。

適用推奨ケース:

  • 機能数が多いアプリケーション(管理画面など)
  • スピード早く開発が必要な場合
  • 複数チームでの並行開発
  • ビジネスロジックが複雑なアプリケーション

弊社で開発中の商品データ管理のWebアプリケーションは複雑なビジネスロジックを扱うため、featuresディレクトリを選定しました。各機能の独立性を保ちながら、効率的な開発を実現できています。

ディレクトリの構成は開発効率、保守性に少なくない影響を与えます。プロジェクトの性質や開発体制に合わせて、適切な構成の選定を行なっていきたいですね。


ASKUL Engineering BLOG

2021 © ASKUL Corporation. All rights reserved.