T-CREATOR

Remix でスケーラブルなディレクトリ設計:routes/リソース/ユーティリティ分割

Remix でスケーラブルなディレクトリ設計:routes/リソース/ユーティリティ分割

Remix アプリケーションを開発する際、プロジェクトの規模が大きくなるにつれて、ファイルやディレクトリの整理が課題になってきます。初期段階では問題なく管理できていたコードも、機能が増えるとファイルが散らばり、保守性が低下してしまうことは少なくありません。

本記事では、Remix プロジェクトにおいて「routes」「リソース」「ユーティリティ」の 3 つの観点からディレクトリを分割し、スケーラブルな設計を実現する方法を解説します。これにより、コードの見通しが良くなり、チーム開発でもスムーズに作業できる環境が整うでしょう。

背景

Remix におけるファイルベースルーティング

Remix はファイルベースルーティングを採用しており、app​/​routes ディレクトリ内のファイル構造が URL パスに直接対応します。例えば、app​/​routes​/​posts.tsx​/​posts というルートに、app​/​routes​/​posts.$id.tsx​/​posts​/​:id に対応するのです。

この仕組みは非常にシンプルでわかりやすい反面、プロジェクトが成長するとルートファイルが増え続け、ディレクトリ構造が複雑になりやすいという特性があります。

以下の図は、Remix のファイルベースルーティングの基本的な対応関係を示しています。

mermaidflowchart LR
  file1["app/routes/_index.tsx"] -->|対応| route1["/"]
  file2["app/routes/posts.tsx"] -->|対応| route2["/posts"]
  file3["app/routes/posts.$id.tsx"] -->|対応| route3["/posts/:id"]
  file4["app/routes/about.tsx"] -->|対応| route4["/about"]

このように、ファイル名とディレクトリ構造が URL に直結するため、構造設計が重要になります。

プロジェクト成長時の課題

小規模なプロジェクトでは、すべてのルートファイルを app​/​routes 直下に置いても問題ありません。しかし、以下のような状況になると管理が困難になってきます。

  • ルートファイルが 20 個、30 個と増えていく
  • 各ルートで使う共通ロジックやユーティリティが散在する
  • API ルートとページルートが混在し、区別がつきにくい
  • データ取得やビジネスロジックがルートファイル内に集中し、ファイルが肥大化する

こうした問題に対処するには、明確な設計方針と分割戦略が必要です。

課題

ルートファイルの肥大化

Remix のルートファイルには、loaderaction、コンポーネントレンダリングロジックなど、複数の責務が集中しがちです。これにより、1 つのファイルが数百行に達し、可読性が低下してしまいます。

typescript// app/routes/posts.$id.tsx の例(アンチパターン)
export async function loader({
  params,
}: LoaderFunctionArgs) {
  // データ取得ロジックが直接記述される
  const post = await db.post.findUnique({
    where: { id: params.id },
  });
  return json({ post });
}
typescriptexport async function action({
  request,
}: ActionFunctionArgs) {
  // 更新ロジックも同じファイル内に記述
  const formData = await request.formData();
  // ... 複雑な処理
}
typescriptexport default function Post() {
  // コンポーネントのレンダリングロジック
  const { post } = useLoaderData<typeof loader>();
  return <div>{/* ... 長い JSX */}</div>;
}

上記のように、すべてを 1 つのファイルに詰め込むと、テストやリファクタリングが困難になります。

コード重複の発生

複数のルートで同じようなデータ取得処理や検証ロジックを使う場合、それぞれのルートファイルにコピー&ペーストしてしまうと、保守性が著しく低下します。

例えば、認証チェックや権限確認、共通のエラーハンドリングなどが各ルートに散らばると、修正時に複数箇所を変更しなければなりません。

API ルートとページルートの混在

Remix では、同じ routes ディレクトリ内に、画面を返すページルートと、JSON を返す API ルートを配置できます。しかし、両者が混在すると、どのファイルが何の役割を持つのか一目で判断しにくくなります。

ルートファイル役割返却内容
app​/​routes​/​posts.tsxページHTML
app​/​routes​/​api.posts.tsxAPIJSON

このように名前で区別することは可能ですが、ファイル数が増えると管理が煩雑になります。

ユーティリティ関数の配置場所が不明確

共通ロジックやヘルパー関数をどこに置くべきか、チーム内で統一されていないと、同じような関数が複数の場所に重複して作られてしまいます。

以下の図は、課題が発生する典型的なディレクトリ構造を示しています。

mermaidflowchart TD
  routes["app/routes/"]
  routes --> route1["posts.tsx<br/>(肥大化)"]
  routes --> route2["posts.$id.tsx<br/>(肥大化)"]
  routes --> route3["api.posts.tsx"]
  routes --> route4["about.tsx"]

  route1 -.->|重複ロジック| duplicate["データ取得<br/>検証<br/>エラー処理"]
  route2 -.->|重複ロジック| duplicate
  route3 -.->|重複ロジック| duplicate

  duplicate -.->|問題| problem["保守性低下<br/>可読性低下"]

このような状態では、新しい機能追加や既存機能の修正が非常に難しくなります。

解決策

ディレクトリ構造の 3 層分割

スケーラブルな Remix プロジェクトを実現するには、以下の 3 つの層に分けてディレクトリを設計することが有効です。

#役割配置場所
1Routes(ルート)URL エンドポイントの定義とルーティングapp​/​routes​/​
2Resources(リソース)ビジネスロジック、データ取得、API 呼び出しapp​/​models​/​, app​/​services​/​
3Utilities(ユーティリティ)汎用的なヘルパー関数、共通処理app​/​utils​/​, app​/​lib​/​

この分割により、各ファイルの責務が明確になり、コードの再利用性と可読性が向上します。

以下の図は、改善後のディレクトリ構造とデータフローを示しています。

mermaidflowchart TD
  routes["Routes Layer<br/>(app/routes/)"]
  resources["Resources Layer<br/>(app/models/, app/services/)"]
  utilities["Utilities Layer<br/>(app/utils/, app/lib/)"]

  routes -->|データ取得を委譲| resources
  resources -->|共通処理を利用| utilities

  routes -.->|役割| r1["URL ルーティング<br/>loader/action 定義<br/>コンポーネント描画"]
  resources -.->|役割| r2["ビジネスロジック<br/>DB アクセス<br/>外部 API 呼び出し"]
  utilities -.->|役割| r3["汎用ヘルパー<br/>検証関数<br/>フォーマッター"]

それぞれの層の具体的な設計方法を、以下で詳しく見ていきましょう。

Routes Layer:ルートファイルの役割を限定する

ルートファイルは、URL エンドポイントの定義と、loaderaction・コンポーネントのエントリポイントに徹するべきです。ビジネスロジックやデータ取得の詳細は、Resources Layer に委譲します。

typescript// app/routes/posts.$id.tsx(改善後)
import {
  json,
  type LoaderFunctionArgs,
} from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
typescript// リソース層から関数をインポート
import { getPostById } from '~/models/post.server';
typescript// loader はデータ取得を委譲するだけ
export async function loader({
  params,
}: LoaderFunctionArgs) {
  const post = await getPostById(params.id);
  return json({ post });
}
typescript// コンポーネントは表示ロジックに集中
export default function Post() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

このように、ルートファイルは薄く保ち、具体的な処理は別の層に分離します。

Resources Layer:ビジネスロジックとデータ取得を集約

データベースアクセスや外部 API 呼び出し、ビジネスロジックは、app​/​models​/​app​/​services​/​ ディレクトリに配置します。これにより、複数のルートから同じロジックを再利用できるようになります。

Models ディレクトリの活用

データベース操作やデータモデルに関連する処理は、app​/​models​/​ に配置します。

typescript// app/models/post.server.ts
import { db } from '~/utils/db.server';
typescript// 投稿を ID で取得する関数
export async function getPostById(id: string) {
  const post = await db.post.findUnique({
    where: { id },
    include: { author: true },
  });

  if (!post) {
    throw new Response('Not Found', { status: 404 });
  }

  return post;
}
typescript// 投稿一覧を取得する関数
export async function getPosts() {
  return db.post.findMany({
    orderBy: { createdAt: 'desc' },
  });
}
typescript// 投稿を作成する関数
export async function createPost(data: {
  title: string;
  content: string;
  authorId: string;
}) {
  return db.post.create({ data });
}

Services ディレクトリの活用

外部 API との連携や、複数のモデルをまたがる複雑な処理は、app​/​services​/​ に配置します。

typescript// app/services/notification.server.ts
typescript// 通知を送信するサービス
export async function sendNotification(
  userId: string,
  message: string
) {
  // 外部 API への POST リクエスト
  const response = await fetch(
    'https://api.example.com/notify',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId, message }),
    }
  );

  return response.json();
}

このように、ビジネスロジックを集約することで、テストやメンテナンスが容易になります。

Utilities Layer:汎用的なヘルパー関数を配置

フォーマット処理、検証ロジック、定数定義など、アプリケーション全体で使う汎用的な関数は、app​/​utils​/​app​/​lib​/​ に配置します。

typescript// app/utils/validation.ts
typescript// メールアドレスの検証
export function isValidEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}
typescript// 必須フィールドの検証
export function validateRequired(
  value: unknown,
  fieldName: string
): string | null {
  if (
    !value ||
    (typeof value === 'string' && value.trim() === '')
  ) {
    return `${fieldName} is required`;
  }
  return null;
}
typescript// app/utils/format.ts
typescript// 日付フォーマット
export function formatDate(date: Date): string {
  return new Intl.DateTimeFormat('ja-JP', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).format(date);
}

これらの関数は、プロジェクト全体で一貫して使用でき、変更時も一箇所の修正で済みます。

ディレクトリ構造の全体像

以下は、3 層分割を適用した具体的なディレクトリ構造の例です。

phpapp/
├── routes/                  # Routes Layer
│   ├── _index.tsx           # トップページ
│   ├── posts.tsx            # 投稿一覧
│   ├── posts.$id.tsx        # 投稿詳細
│   ├── posts.new.tsx        # 新規投稿
│   └── api.posts.tsx        # API エンドポイント
│
├── models/                  # Resources Layer(データモデル)
│   ├── post.server.ts       # 投稿関連の処理
│   └── user.server.ts       # ユーザー関連の処理
│
├── services/                # Resources Layer(サービス)
│   ├── notification.server.ts
│   └── auth.server.ts
│
└── utils/                   # Utilities Layer
    ├── validation.ts        # 検証関数
    ├── format.ts            # フォーマット関数
    └── db.server.ts         # DB 接続設定
ディレクトリ用途
app​/​routes​/​ルーティングとエンドポイントposts.tsx, api.posts.tsx
app​/​models​/​データベース操作post.server.ts
app​/​services​/​外部 API、複合処理notification.server.ts
app​/​utils​/​汎用ヘルパーvalidation.ts, format.ts

図で理解できる要点:

  • Routes はエンドポイントの定義に専念し、ビジネスロジックは持たない
  • Models と Services がデータとロジックを担当
  • Utils は全レイヤーから利用される共通処理を提供

具体例

ブログアプリケーションへの適用

実際のブログアプリケーションを例に、3 層分割をどのように適用するか見ていきましょう。

ディレクトリ構成

phpapp/
├── routes/
│   ├── _index.tsx
│   ├── posts._index.tsx
│   ├── posts.$id.tsx
│   ├── posts.new.tsx
│   └── api.posts.tsx
├── models/
│   └── post.server.ts
├── services/
│   └── email.server.ts
└── utils/
    ├── validation.ts
    └── db.server.ts

Routes Layer の実装例

まず、投稿一覧ページのルートファイルを作成します。

typescript// app/routes/posts._index.tsx
import { json } from '@remix-run/node';
import { useLoaderData, Link } from '@remix-run/react';
typescript// モデル層から関数をインポート
import { getPosts } from '~/models/post.server';
import { formatDate } from '~/utils/format';
typescript// loader はデータ取得を委譲
export async function loader() {
  const posts = await getPosts();
  return json({ posts });
}
typescript// コンポーネントは表示に専念
export default function PostsIndex() {
  const { posts } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>投稿一覧</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>
              {post.title}
            </Link>
            <span>{formatDate(post.createdAt)}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

次に、新規投稿ページを作成します。

typescript// app/routes/posts.new.tsx
import {
  json,
  redirect,
  type ActionFunctionArgs,
} from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
typescriptimport { createPost } from '~/models/post.server';
import { validateRequired } from '~/utils/validation';
typescript// action で入力を検証し、モデル層に委譲
export async function action({
  request,
}: ActionFunctionArgs) {
  const formData = await request.formData();
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  const errors = {
    title: validateRequired(title, 'タイトル'),
    content: validateRequired(content, '本文'),
  };

  if (errors.title || errors.content) {
    return json({ errors }, { status: 400 });
  }

  await createPost({ title, content, authorId: 'user123' });
  return redirect('/posts');
}
typescriptexport default function NewPost() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method='post'>
      <div>
        <label htmlFor='title'>タイトル</label>
        <input type='text' id='title' name='title' />
        {actionData?.errors?.title && (
          <p>{actionData.errors.title}</p>
        )}
      </div>
      <div>
        <label htmlFor='content'>本文</label>
        <textarea id='content' name='content' />
        {actionData?.errors?.content && (
          <p>{actionData.errors.content}</p>
        )}
      </div>
      <button type='submit'>投稿する</button>
    </Form>
  );
}

Resources Layer の実装例

データベース操作をまとめたモデルファイルを作成します。

typescript// app/models/post.server.ts
import { db } from '~/utils/db.server';
typescript// 投稿一覧を取得
export async function getPosts() {
  return db.post.findMany({
    orderBy: { createdAt: 'desc' },
    select: {
      id: true,
      title: true,
      createdAt: true,
    },
  });
}
typescript// ID で投稿を取得
export async function getPostById(id: string) {
  const post = await db.post.findUnique({
    where: { id },
  });

  if (!post) {
    throw new Response('投稿が見つかりません', {
      status: 404,
    });
  }

  return post;
}
typescript// 新規投稿を作成
export async function createPost(data: {
  title: string;
  content: string;
  authorId: string;
}) {
  return db.post.create({
    data: {
      title: data.title,
      content: data.content,
      authorId: data.authorId,
    },
  });
}

外部サービス連携をサービスファイルに分離します。

typescript// app/services/email.server.ts
typescript// 投稿作成時にメール通知を送る
export async function notifyNewPost(
  postTitle: string,
  authorEmail: string
) {
  // 外部メール API への送信処理
  console.log(
    `新しい投稿: ${postTitle}${authorEmail} に通知しました`
  );
}

Utilities Layer の実装例

汎用的な検証関数とフォーマット関数を用意します。

typescript// app/utils/validation.ts
typescript// 必須フィールド検証
export function validateRequired(
  value: unknown,
  fieldName: string
): string | null {
  if (
    !value ||
    (typeof value === 'string' && value.trim() === '')
  ) {
    return `${fieldName}は必須です`;
  }
  return null;
}
typescript// 文字数制限検証
export function validateMaxLength(
  value: string,
  maxLength: number,
  fieldName: string
): string | null {
  if (value.length > maxLength) {
    return `${fieldName}${maxLength}文字以内で入力してください`;
  }
  return null;
}
typescript// app/utils/format.ts
typescript// 日付を日本語形式でフォーマット
export function formatDate(date: Date | string): string {
  const d =
    typeof date === 'string' ? new Date(date) : date;
  return new Intl.DateTimeFormat('ja-JP', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).format(d);
}

以下の図は、具体例における各層の連携を示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Route as Routes<br/>(posts.new.tsx)
  participant Model as Models<br/>(post.server.ts)
  participant Utils as Utils<br/>(validation.ts)
  participant DB as データベース

  User->>Route: フォーム送信
  Route->>Utils: validateRequired()
  Utils-->>Route: 検証結果
  Route->>Model: createPost()
  Model->>DB: INSERT
  DB-->>Model: 完了
  Model-->>Route: 作成済み投稿
  Route-->>User: リダイレクト

図で理解できる要点:

  • ユーザーからのリクエストは Routes が受け取る
  • 検証は Utils、データ操作は Models に委譲される
  • 各層が明確に分離され、責務が明確になっている

API ルートの分離

API エンドポイントとページルートを明確に区別するため、API ルートには api. プレフィックスを付けることが推奨されます。

typescript// app/routes/api.posts.tsx
import {
  json,
  type LoaderFunctionArgs,
} from '@remix-run/node';
typescriptimport { getPosts } from '~/models/post.server';
typescript// JSON を返す API エンドポイント
export async function loader({
  request,
}: LoaderFunctionArgs) {
  const posts = await getPosts();
  return json({ posts });
}

このファイルは ​/​api​/​posts というパスでアクセス可能になり、JSON データを返します。ページルートと分離されているため、役割が明確です。

ルートパス返却形式用途
posts.tsx​/​postsHTMLページ表示
api.posts.tsx​/​api​/​postsJSONデータ API

テストの容易化

3 層分割により、各層を独立してテストできるようになります。

typescript// models/post.server.test.ts
import { describe, it, expect } from 'vitest';
import { getPostById } from './post.server';
typescriptdescribe('getPostById', () => {
  it('存在する投稿を取得できる', async () => {
    const post = await getPostById('123');
    expect(post).toBeDefined();
    expect(post.id).toBe('123');
  });

  it('存在しない投稿は 404 エラーを投げる', async () => {
    await expect(getPostById('invalid')).rejects.toThrow();
  });
});

ルートファイルから独立しているため、モデル層のテストが簡潔に書けます。

まとめ

本記事では、Remix プロジェクトにおけるスケーラブルなディレクトリ設計として、「routes」「リソース」「ユーティリティ」の 3 層分割を提案しました。

設計の重要ポイント

  • Routes Layer: URL ルーティングとエントリポイントに徹し、ビジネスロジックは持たない
  • Resources Layer: データ取得とビジネスロジックを modelsservices に集約し、再利用性を高める
  • Utilities Layer: 汎用的なヘルパー関数を utils に配置し、プロジェクト全体で一貫して使用する

この設計により、以下のメリットが得られます。

#メリット説明
1可読性の向上各ファイルの責務が明確になり、コードが追いやすくなる
2保守性の向上ロジックが集約され、変更時の影響範囲が限定される
3テストの容易化各層を独立してテストでき、品質が向上する
4チーム開発の効率化構造が明確で、新メンバーも理解しやすい

プロジェクトの初期段階からこの設計を取り入れることで、将来的な拡張にも柔軟に対応できる基盤が整います。小規模なプロジェクトであっても、この構造を意識しておくことで、成長時のリファクタリングコストを大幅に削減できるでしょう。

ぜひ、次の Remix プロジェクトで実践してみてください。

関連リンク