ZOZOTOWN フロントエンドにおけるディレクトリの分割戦略

ZOZOTOWN フロントエンドにおけるディレクトリの分割戦略

はじめに

こんにちは。ZOZOTOWN開発本部フロントエンドの菊地(@hiro0218)です。

2021年、ZOZOTOWNはフロントエンドリプレイスを開始しました。現在、ホームページや商品一覧ページなど主要なページのNext.js化が完了し、運用フェーズに入っています。詳細は以下の記事を参照してください。

techblog.zozo.com

開始当初、他社事例を参考にしながら、よくある課題を未然に防ぐディレクトリ構成を設計しました。本記事では、約4年にわたる運用で改善を重ねてきたディレクトリの分割戦略について紹介します。

※本記事は2025年8月にちょっと株式会社との合同勉強会で発表した内容を基にしています。

speakerdeck.com

背景

現在、私が携わっている領域におけるフロントエンド開発は4チーム、合計30名強で運用しています。この規模で効率的に開発を進めるため、ディレクトリ構成は重要な要素となります。

避けたい課題

過去の経験や他社事例から、以下のような課題を未然に防ぐ必要がありました。

  • 配置場所が曖昧:「なんとなくここに置いた」コンポーネントが増え、後から見つけにくい
  • 再利用性が低い:ページ固有の処理を含むコンポーネントは、他のページで使いにくい
  • 影響範囲が不明確:コンポーネントを変更する際、どこで使われているか把握しづらい
  • チーム開発が非効率:メンバーごとに配置ルールの解釈が異なり、コードレビューで迷う

新規開発にあたり、これらを考慮した設計戦略を採用しました。

解決アプローチ:責務分離パターン

役割を明確にした配置(責務分離)を行いました。この設計を選んだ理由は「迷わない分類」です。新しいコンポーネントを作成する際、配置場所で迷う時間を最小化し、メンバー間で判断が分かれないようにすることを重視しました。

この設計では、以下の2点を重視しています。

  1. 運用中の移動最小化: 一度配置したコンポーネントは、基本的に移動が不要
  2. 予測可能な構造: ディレクトリ階層が深くならず、探索しやすい

この設計を実現するため、以下の2層構造を採用しました。

  1. コンポーネント層src/components/
    • 役割別に5つのディレクトリに分類
  2. UI インフラ層src/ui/
    • コンポーネントが利用する共通基盤

コンポーネント層の実装を進める中で、UIインフラ層の必要性が見えてきました。

コンポーネント層と UI インフラ層の関係

コンポーネント層と UI インフラ層の関係図

コンポーネント層の設計

コンポーネントを5つの役割に分類し、それぞれの責務と依存関係を定義しています。

ディレクトリ構成

他社事例を参考にして5つのディレクトリに分割した構成を採用しました。

src/components/
├── UI/
├── Models/
├── Pages/
├── Layouts/
└── Functional/

Next.jsのルーティング用ディレクトリ(src/pages)と区別するため、頭を大文字としています。

責務ごとの分類

各ディレクトリの役割は以下のように定義されています。

名前 役割 格納するコンポーネント例
UI 純粋なUI要素 Button, Collapse, Image...
Models ドメインロジックがある ProductList, BrandList...
Pages ページ専用 HomePage, SearchPage...
Layouts アプリに関わるレイアウト Header, Footer...
Functional UIを伴わないアプリケーション機能 Analytics, GlobalStore...

この分類のポイントは「新しくコンポーネントを作るとき、どこに配置するか迷わない」ことです。曖昧な判断基準では、メンバーごとに解釈が分かれ、レビュー時の議論コストが増大します。

コンポーネント作成時の判断フロー

コンポーネントを作成する際は、以下の順で判断します。

  1. 特定のページでのみ使用するか?

    • YES → src/components/Pages/ に配置
    • 判断基準:他のページでは再利用されない固有の実装
    • 例:HomePage、SearchPage
  2. アプリ全体のレイアウト構造に関わるか?

    • YES → src/components/Layouts/ に配置
    • 判断基準:アプリ全体の構造・骨格を担当
    • 例:Header、Footer
  3. 特定のドメインロジックを含み複数ページで使用するか?

    • YES → src/components/Models/ に配置
    • 判断基準:特定のドメインに関連する機能を持つコンポーネント
    • 例:ProductList、BrandList
  4. UIのみの汎用的なコンポーネントか?

    • YES → src/components/UI/ に配置
    • 判断基準:ビジネスロジックを含まない純粋なUI要素
    • 例:Button、Collapse、Image
    • 重要:ドメイン固有の名前を持つコンポーネントも、データを表示するだけならUIに配置。Modelsとの違いはデータ取得やビジネスルールを含むかどうか
  5. UIを伴わないアプリケーション機能か?

    • YES → src/components/Functional/ に配置
    • 判断基準:直接ユーザーに表示されないアプリケーション機能
    • 例:Analytics、GlobalStore

実装例

UIは純粋な表示、Modelsはドメインロジックという責務の分離を、実際のコードで確認します。

UI コンポーネント

propsで受け取ったデータを表示するだけのシンプルな実装です。

// UI/ProductCard - UI コンポーネント
export const ProductCard = ({ name, price, image }) => (
  <Card>
    <Image src={image} />
    <Title>{name}</Title>
    <Price>{price}</Price>
  </Card>
);

Models コンポーネント

データを取得し、UIコンポーネントを組み合わせて表示します。

// Models/ProductList - ドメインロジック + UI を利用
export const ProductList = (props) => {
  const { products } = useProductData(props);

  return (
    <Grid>
      {products?.map((product) => (
        <ProductCard key={product.id} {...product} />
      ))}
    </Grid>
  );
};

ProductCard(UI)は純粋な表示、ProductList(Models)はデータ取得とUIの組み合わせという責務の違いが分かります。

テストファイルの配置

コンポーネントの分類と同様に、テストファイルの配置もルールが必要です。テストやStorybookのファイルは、対象のコンポーネントファイルと同じディレクトリに配置することで、関連するファイルを一箇所にまとめて管理しています。

components/UI/Button/
├── Button.tsx
├── Button.test.tsx
├── Button.stories.tsx
└── Button.module.css

この配置により、実装とテストの対応関係が分かりやすくなり、ファイル間の移動もスムーズになります。

依存関係のルール

コンポーネント間の依存関係は「自分の横か下にある分類のコンポーネントのみ参照してよい」という原則に従います。

依存の基本原則:上位から下位への一方向のみ許可。

  • Pagessrc/components/Pages): Models、UI、Functionalを参照可能
  • Models・Layouts: UIとFunctionalを参照可能
  • UI: Functionalのみ参照可能
  • Functional: 外部依存なし(最下位)

各ディレクトリは、同じディレクトリ内のコンポーネント同士も参照可能です(Pagesを除く)。

注記: ここでの「Pages」は src/components/Pages(ページ専用コンポーネント)を指します。Next.jsのルーティング用ディレクトリである src/pages は、アプリケーションのエントリーポイントとして特別な役割を持つため、Layoutsを含むすべてのコンポーネントを参照可能です。

この依存関係により、循環参照を防ぎ、変更の影響範囲を予測しやすくなります。

依存関係のチェック

設計したディレクトリ構成のルールが守られるよう、以下のツールを導入しています。

ディレクトリ間の依存ルール

eslint-plugin-strict-dependenciesにより、誤った依存関係を自動的に検出し、コードレビュー時の負担を軽減しています。

'strict-dependencies/strict-dependencies': [
  'error',
  [
    // Pages コンポーネントの依存ルール
    {
      module: 'src/components/Pages',
      allowReferenceFrom: ['src/pages'], // Next.jsのルーティング用ディレクトリからのみ参照可能
      allowSameModule: false,
    },
    // Models コンポーネントの依存ルール
    {
      module: 'src/components/Models',
      allowReferenceFrom: ['src/components/Pages'],
      allowSameModule: true,
    },
    // Layouts コンポーネントの依存ルール
    {
      module: 'src/components/Layouts',
      allowReferenceFrom: ['src/pages'], // Next.jsのルーティング用ディレクトリからの参照を許可
      allowSameModule: true,
    },
    // UI コンポーネントの依存ルール
    {
      module: 'src/components/UI',
      allowReferenceFrom: [
        'src/pages',              // Next.jsのルーティング用ディレクトリ
        'src/components/Pages',   // ページ専用コンポーネント
        'src/components/Layouts',
        'src/components/Models',
      ],
      allowSameModule: true,
    },
    // (省略)他にも多数のルールを定義...
  ]
]
Pages ディレクトリの特殊な設定

上記の設定において、Pagesディレクトリ(src/components/Pages)のみ allowSameModule: false を採用しています。これは、ページ間の独立性を保証するための設計です。

Pagesディレクトリは Cart/Home/Search/ のようにページごとにサブディレクトリが分かれており、各ページ専用のコンポーネントが配置されています。allowSameModule: false により、あるページのコンポーネントが別のページのコンポーネントを参照することを禁止しています。

// NG: Cart ページが Home ページのコンポーネントを参照
import { HomeComponent } from "../Home/HomeComponent";

もし複数のページで使いたいコンポーネントが出てきた場合、本来はModels、UI、Layoutsのいずれかに配置すべきコンポーネントである可能性が高いため、適切なディレクトリへの移動を検討します。

循環参照のチェック

dependency-cruiserを導入し、PR上で循環参照を自動検出しています。これにより、複雑な依存関係による保守性の低下を未然に防いでいます。

現在の運用規模

この設計で約4年間運用した結果、執筆時点では以下のような規模でコンポーネントが配置されています。

ディレクトリ 割合
UI 約 50%
Functional 約 20%
Models 約 13%
Pages 約 11%
Layouts 約 6%

UI インフラ層の設計

コンポーネント層で使用する共通基盤として、スタイル定義やテーマシステムを管理しています。

ディレクトリ構成

src/ui/
├── themes/             # 色・mixin・関数・ブランドテーマ定義
│   ├── mixin/          # Mixin ヘルパー
│   ├── function/       # 関数ヘルパー
│   └── themeVariants/  # ブランド別テーマ
├── styled/             # Emotion のスタイル関数群
├── libs/               # UI ユーティリティ
├── constants/          # UI 共通定数
└── stylelint-plugins/  # カスタム Stylelint ルール

統一されたスタイル定義

ZOZOTOWNではCSS in JS(Emotion)を採用しています。SassやPostCSSのようにmixinやfunctionを用意し、ホバーエフェクトやタイポグラフィ計算など、スタイリングの共通処理を提供しています。

const Button = styled.button`
  ${({ theme }) => theme.mixin.hoverOpacityEffect()};
`;

const Label = styled.span`
  font-weight: ${({ theme }) => theme.function.fontWeight("bold")};
`;

実装者はスタイルの詳細を意識せず、一貫性のあるUIを構築できます。

品質担保

Stylelintプラグインにより、EmotionのSSR制約:first-child等)やline-heightなど、プロジェクト固有ルールを自動検証しています。

テーマシステムの活用

これらの基盤を活用した具体例として、ZOZOTOWNではThemeProviderを用いたテーマシステムを導入しています。

ブランドテーマ

ZOZOTOWNには、特定のブランド向けにブランドカラーを反映しているページがあります。対象ブランド数は多くありませんが、拡張性を考慮してテーマシステムを利用しています。

const BRAND_COLOR = "#000"; // ブランドカラー

export const colors: ColorTheme = {
  button: {
    primary: { background: BRAND_COLOR },
  },
  text: {
    red: BRAND_COLOR,
    blue: BRAND_COLOR,
  },
};

通常のボタンとブランドテーマを適用したボタンの比較

同じ「カートに入れる」ボタンでも、ブランドページでは自動的にそのブランド固有のカラーが適用されます。

サイトジャック

ブランド出店の施策で「サイトジャック」と称して、ZOZOTOWNのカラーを期間限定でブランドカラーに置き換えることがあります。

const JACK_COLOR = "#FF1493"; // サイトジャックカラー

export const siteJackTheme: ColorTheme = {
  header: { background: JACK_COLOR },
  navigation: { border: JACK_COLOR },
  button: { primary: { background: JACK_COLOR } },
  // ...
};

このテーマを適用すると、ZOZOTOWNのヘッダーやナビゲーションなどの主要な要素が、期間限定でブランド固有のカラーへ一括変更できます。

まとめ

本記事では、ZOZOTOWNフロントエンドにおけるディレクトリの分割戦略について紹介しました。コンポーネントを5つの役割に分類し、判断フローと依存関係のルールを設けることで、配置場所で迷わない設計を実現しています。また、自動チェックの仕組みやUIインフラ層の整備により、一貫性のある開発環境を構築しています。

もちろん、ファイル数の増加により見直しを検討している箇所も出てきています。しかしながら、当初の設計から大きく変えずとも大規模開発に耐えうる基盤が維持できており、新規メンバーもスムーズに開発に参加できる状況です。責務分離を意識したディレクトリ設計は、長期的な開発において有効なアプローチであると実感しています。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー