T-CREATOR

htmx の BFF(Backend for Frontend)設計:コントローラ分割とパーシャルの責務

htmx の BFF(Backend for Frontend)設計:コントローラ分割とパーシャルの責務

htmx を使った Web アプリケーション開発では、従来の SPA とは異なるアーキテクチャが求められます。特に、バックエンドとフロントエンドの境界線をどこに引くか、どのようにコントローラを設計するかは、保守性と拡張性に直結する重要な判断です。

本記事では、htmx を活用した BFF パターンにおけるコントローラの分割方法と、パーシャル HTML の責務について、実践的なアプローチを解説していきます。

背景

htmx とは何か

htmx は、HTML 属性を使って AJAX リクエストや DOM 操作を行えるライブラリです。JavaScript を書かずに、宣言的な方法でインタラクティブな Web アプリケーションを構築できます。

typescript<!-- htmx の基本的な使い方 -->
<button
  hx-get="/api/users"
  hx-target="#user-list"
  hx-swap="innerHTML"
>
  ユーザー一覧を読み込む
</button>

<div id="user-list">
  <!-- ここにユーザー一覧が挿入されます -->
</div>

この例では、ボタンをクリックすると ​/​api​/​users へ GET リクエストが送信され、レスポンスの HTML が #user-list 要素の内側に挿入されます。

BFF パターンの必要性

従来の SPA では、フロントエンドが JSON API を呼び出し、クライアント側でレンダリングを行います。一方、htmx ではサーバーが HTML を返すため、フロントエンド専用の API 層が必要になるのです。

以下の図は、htmx を使った場合のアーキテクチャの全体像を示します。

mermaidflowchart TB
  browser["ブラウザ<br/>(htmx)"] -->|"HTML リクエスト"| bff["BFF 層<br/>(コントローラ)"]
  bff -->|"データ取得"| service["サービス層<br/>(ビジネスロジック)"]
  service -->|"永続化"| db[("データベース")]
  bff -->|"HTML レンダリング"| template["テンプレート<br/>(パーシャル)"]
  template -->|"HTML レスポンス"| browser

図で理解できる要点

  • BFF 層がフロントエンドとバックエンドの橋渡しを担当
  • テンプレートエンジンでサーバーサイドレンダリングを実施
  • サービス層はビジネスロジックに専念

この構成により、フロントエンドの要求に最適化された HTML を効率的に提供できます。

従来の MVC との違い

従来の MVC パターンでは、1 つのコントローラが画面全体を担当していました。しかし htmx では、画面の一部分だけを更新する「パーシャル更新」が中心となるため、設計方針を見直す必要があります。

#項目従来の MVChtmx + BFF
1レスポンス形式完全な HTML ページパーシャル HTML 片
2コントローラの粒度画面単位機能・コンポーネント単位
3状態管理セッション中心サーバー側で完結
4ルーティングページ遷移ベースエンドポイント細分化

課題

コントローラの肥大化

htmx を使うと、画面の各部分に対応するエンドポイントが増えます。これらを 1 つのコントローラにまとめると、数百行に及ぶ巨大なファイルになってしまいます。

typescript// アンチパターン: すべてを 1 つのコントローラに詰め込む
export class UserController {
  // ユーザー一覧表示
  async listUsers(req, res) {
    /* ... */
  }

  // ユーザー検索
  async searchUsers(req, res) {
    /* ... */
  }

  // ユーザー詳細
  async getUserDetail(req, res) {
    /* ... */
  }

  // ユーザー編集フォーム
  async editUserForm(req, res) {
    /* ... */
  }

  // ユーザー更新
  async updateUser(req, res) {
    /* ... */
  }

  // ユーザー削除確認
  async deleteConfirm(req, res) {
    /* ... */
  }

  // ... さらに続く
}

このような設計では、以下の問題が発生します。

責務の曖昧さ

パーシャル HTML を返すエンドポイントと、完全なページを返すエンドポイントが混在すると、どこで何を担当しているのか分かりにくくなります。

typescript// どちらも同じコントローラ内に存在
async showUserPage(req, res) {
  // 完全な HTML ページを返す
  return render('users/index.html', data);
}

async loadUserList(req, res) {
  // パーシャル HTML を返す
  return render('users/_list.html', data);
}

以下の図は、責務が曖昧になった場合の問題点を示しています。

mermaidflowchart LR
  request["リクエスト"] --> controller["巨大なコントローラ"]
  controller --> page["完全ページ"]
  controller --> partial1["パーシャル A"]
  controller --> partial2["パーシャル B"]
  controller --> partial3["パーシャル C"]
  controller --> api["JSON API"]

  style controller fill:#ffcccc

図で理解できる要点

  • 1 つのコントローラが多様な形式のレスポンスを担当
  • 役割が不明瞭で保守が困難
  • テストの複雑度が増大

テストの複雑化

エンドポイントが増えると、それぞれに対するテストケースも増加します。また、パーシャルとページ全体のテストを同じファイルに書くと、テストコードの見通しが悪くなります。

解決策

コントローラの分割戦略

コントローラは「機能」と「レスポンス形式」の 2 軸で分割するのが効果的です。

1. 機能別の分割

ユーザー管理、商品管理など、ドメインごとにコントローラを分けます。

typescript// controllers/users/
// ├── UserPageController.ts       // 完全ページ用
// ├── UserListController.ts       // 一覧パーシャル用
// ├── UserDetailController.ts     // 詳細パーシャル用
// └── UserFormController.ts       // フォーム関連

このディレクトリ構造により、関連するコントローラを 1 箇所にまとめつつ、それぞれの責務を明確化できます。

2. レスポンス形式別の分割

完全なページを返すコントローラと、パーシャル HTML を返すコントローラを分離します。

typescript// ページ全体を返すコントローラ
import { Request, Response } from 'express';

export class UserPageController {
  // ユーザー管理画面の初期表示
  async index(req: Request, res: Response) {
    // 初期データの取得
    const users = await this.userService.getUsers();

    // 完全な HTML ページをレンダリング
    return res.render('users/index.html', { users });
  }
}

上記は初期ページロード時に使用されるコントローラです。画面全体の HTML を生成し、htmx による動的な更新の土台を提供します。

typescript// パーシャル HTML を返すコントローラ
import { Request, Response } from 'express';

export class UserListController {
  // ユーザー一覧のパーシャル更新
  async list(req: Request, res: Response) {
    // クエリパラメータから検索条件を取得
    const { search, page = 1, limit = 20 } = req.query;

    // データ取得
    const users = await this.userService.searchUsers({
      search: search as string,
      page: Number(page),
      limit: Number(limit),
    });

    // パーシャル HTML のみをレンダリング
    return res.render('users/_list.html', { users });
  }
}

このコントローラは htmx からの AJAX リクエストに応答し、画面の一部分だけを更新するための HTML 片を返します。

パーシャルの命名規則

パーシャル HTML ファイルには、アンダースコアのプレフィックスを付けることで、完全なページと区別できます。

typescript// views/users/
// ├── index.html          // 完全ページ
// ├── _list.html          // 一覧パーシャル
// ├── _detail.html        // 詳細パーシャル
// ├── _form.html          // フォームパーシャル
// └── _search_bar.html    // 検索バーパーシャル

この命名規則により、ファイル一覧を見ただけで、どれがパーシャルか判断できます。

ルーティングの設計

エンドポイントの URL にも規則を設けることで、API の見通しが良くなります。

typescript// routes/users.ts
import express from 'express';
import { UserPageController } from './controllers/users/UserPageController';
import { UserListController } from './controllers/users/UserListController';

const router = express.Router();
const pageController = new UserPageController();
const listController = new UserListController();

// 完全ページ用のエンドポイント
router.get('/users', pageController.index);

// パーシャル用のエンドポイント(/partials プレフィックス)
router.get('/partials/users/list', listController.list);
router.get(
  '/partials/users/:id/detail',
  listController.detail
);

export default router;

​/​partials プレフィックスを付けることで、パーシャル専用のエンドポイントであることが一目瞭然になります。

以下の図は、ルーティング設計の全体像を示します。

mermaidflowchart TD
  root["/users"] -->|"初回アクセス"| page["UserPageController<br/>完全 HTML"]

  partial_root["/partials/users/*"] --> list_route["/list"]
  partial_root --> detail_route["/:id/detail"]
  partial_root --> form_route["/:id/form"]

  list_route --> list_ctrl["UserListController"]
  detail_route --> detail_ctrl["UserDetailController"]
  form_route --> form_ctrl["UserFormController"]

  list_ctrl --> list_tmpl["_list.html"]
  detail_ctrl --> detail_tmpl["_detail.html"]
  form_ctrl --> form_tmpl["_form.html"]

図で理解できる要点

  • 完全ページとパーシャルで URL 体系を分離
  • コントローラとテンプレートが 1 対 1 で対応
  • エンドポイントの役割が URL から推測可能

サービス層との連携

コントローラは HTTP リクエストの処理とレスポンスの生成に専念し、ビジネスロジックはサービス層に委譲します。

typescript// services/UserService.ts
export class UserService {
  constructor(private userRepository: UserRepository) {}

  // ビジネスロジック: ユーザー検索
  async searchUsers(params: SearchParams) {
    const { search, page, limit } = params;

    // 検索条件の構築
    const where = search
      ? { name: { contains: search } }
      : {};

    // データ取得
    const users = await this.userRepository.findMany({
      where,
      skip: (page - 1) * limit,
      take: limit,
    });

    return users;
  }
}

サービス層では、データの取得や加工といったビジネスロジックを実装します。コントローラから独立しているため、別のコントローラからも再利用できます。

typescript// コントローラでサービスを利用
export class UserListController {
  constructor(private userService: UserService) {}

  async list(req: Request, res: Response) {
    // サービス層にロジックを委譲
    const users = await this.userService.searchUsers({
      search: req.query.search as string,
      page: Number(req.query.page || 1),
      limit: Number(req.query.limit || 20),
    });

    // レンダリングに専念
    return res.render('users/_list.html', { users });
  }
}

この分離により、コントローラはシンプルになり、テストも書きやすくなります。

具体例

ユーザー管理画面の実装

実際にユーザー管理画面を構築する例を見ていきましょう。

完全ページのコントローラ

typescript// controllers/users/UserPageController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';

export class UserPageController {
  constructor(private userService: UserService) {}

  // ユーザー管理画面の初期表示
  async index(req: Request, res: Response) {
    try {
      // 初期データの取得(最初の 20 件)
      const users = await this.userService.getUsers({
        page: 1,
        limit: 20,
      });

      // 完全な HTML ページをレンダリング
      return res.render('users/index.html', {
        users,
        title: 'ユーザー管理',
      });
    } catch (error) {
      console.error('Error loading user page:', error);
      return res.status(500).render('error.html', {
        message: 'ページの読み込みに失敗しました',
      });
    }
  }
}

このコントローラは、ユーザー管理画面の初回アクセス時に呼び出されます。画面全体の HTML を生成し、初期データを埋め込んで返します。

パーシャルのコントローラ

typescript// controllers/users/UserListController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';

export class UserListController {
  constructor(private userService: UserService) {}

  // ユーザー一覧のパーシャル更新
  async list(req: Request, res: Response) {
    try {
      const {
        search,
        page = '1',
        limit = '20',
      } = req.query;

      // サービス層でデータ取得
      const users = await this.userService.searchUsers({
        search: search as string,
        page: parseInt(page as string, 10),
        limit: parseInt(limit as string, 10),
      });

      // パーシャル HTML をレンダリング
      return res.render('users/_list.html', { users });
    } catch (error) {
      console.error('Error loading user list:', error);
      return res
        .status(500)
        .send('<div class="error">読み込みエラー</div>');
    }
  }
}

htmx からの検索やページネーションのリクエストに応答し、ユーザー一覧部分のみを更新するための HTML を返します。

詳細表示のコントローラ

typescript// controllers/users/UserDetailController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';

export class UserDetailController {
  constructor(private userService: UserService) {}

  // ユーザー詳細のパーシャル表示
  async detail(req: Request, res: Response) {
    try {
      const { id } = req.params;

      // ユーザー情報の取得
      const user = await this.userService.getUserById(id);

      if (!user) {
        return res
          .status(404)
          .send(
            '<div class="error">ユーザーが見つかりません</div>'
          );
      }

      // 詳細パーシャルをレンダリング
      return res.render('users/_detail.html', { user });
    } catch (error) {
      console.error('Error loading user detail:', error);
      return res
        .status(500)
        .send('<div class="error">読み込みエラー</div>');
    }
  }
}

ユーザーの詳細情報を表示するパーシャルを返すコントローラです。一覧から詳細への遷移時に使用されます。

テンプレートの実装

完全ページのテンプレート

html<!-- views/users/index.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>{{ title }}</title>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  </head>
  <body>
    <div class="container">
      <h1>{{ title }}</h1>

      <!-- 検索バー -->
      <div class="search-section">
        <input
          type="text"
          name="search"
          placeholder="ユーザー名で検索"
          hx-get="/partials/users/list"
          hx-trigger="keyup changed delay:500ms"
          hx-target="#user-list"
          hx-include="[name='search']"
        />
      </div>

      <!-- ユーザー一覧(初期データを含む) -->
      <div id="user-list">
        {% include "users/_list.html" %}
      </div>
    </div>
  </body>
</html>

完全ページのテンプレートでは、HTML の基本構造を定義し、htmx の設定を行います。検索バーにはデバウンス処理を設定し、入力の 500ms 後に自動検索が実行されます。

パーシャルのテンプレート

html<!-- views/users/_list.html -->
<table class="user-table">
  <thead>
    <tr>
      <th>ID</th>
      <th>名前</th>
      <th>メール</th>
      <th>操作</th>
    </tr>
  </thead>
  <tbody>
    {% for user in users %}
    <tr>
      <td>{{ user.id }}</td>
      <td>{{ user.name }}</td>
      <td>{{ user.email }}</td>
      <td>
        <button
          hx-get="/partials/users/{{ user.id }}/detail"
          hx-target="#detail-modal"
          hx-swap="innerHTML"
        >
          詳細
        </button>
      </td>
    </tr>
    {% endfor %}
  </tbody>
</table>

パーシャルテンプレートは、一覧のテーブル部分のみを定義します。このファイルは完全ページからも、AJAX リクエストからも使用されます。

以下の図は、テンプレートの構造と関係性を示します。

mermaidflowchart TB
  index["index.html<br/>(完全ページ)"] -->|"include"| list["_list.html"]
  index -->|"include"| search["_search_bar.html"]

  ajax_list["AJAX リクエスト<br/>(一覧更新)"] -->|"レンダリング"| list
  ajax_detail["AJAX リクエスト<br/>(詳細表示)"] -->|"レンダリング"| detail["_detail.html"]

  style index fill:#e1f5ff
  style list fill:#fff4e1
  style search fill:#fff4e1
  style detail fill:#fff4e1

図で理解できる要点

  • 完全ページがパーシャルを組み合わせて構成される
  • 同じパーシャルが初期ロードと AJAX で再利用される
  • テンプレートの責務が明確に分離されている

フォーム送信の実装

フォームの表示と送信処理も、コントローラを分けて実装します。

フォーム表示のコントローラ

typescript// controllers/users/UserFormController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';

export class UserFormController {
  constructor(private userService: UserService) {}

  // 編集フォームの表示
  async editForm(req: Request, res: Response) {
    try {
      const { id } = req.params;

      // 既存ユーザー情報の取得
      const user = await this.userService.getUserById(id);

      if (!user) {
        return res
          .status(404)
          .send(
            '<div class="error">ユーザーが見つかりません</div>'
          );
      }

      // フォームパーシャルをレンダリング
      return res.render('users/_form.html', {
        user,
        action: `/api/users/${id}`,
        method: 'PUT',
      });
    } catch (error) {
      console.error('Error loading edit form:', error);
      return res
        .status(500)
        .send('<div class="error">読み込みエラー</div>');
    }
  }
}

フォーム表示用のコントローラでは、既存データを取得してフォームに埋め込みます。

フォーム送信のコントローラ

typescript// controllers/users/UserUpdateController.ts
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';

export class UserUpdateController {
  constructor(private userService: UserService) {}

  // ユーザー情報の更新
  async update(req: Request, res: Response) {
    try {
      const { id } = req.params;
      const { name, email } = req.body;

      // バリデーション
      if (!name || !email) {
        return res
          .status(400)
          .render('users/_form_error.html', {
            errors: {
              name: !name ? '名前は必須です' : null,
              email: !email ? 'メールは必須です' : null,
            },
          });
      }

      // 更新処理
      const updatedUser = await this.userService.updateUser(
        id,
        {
          name,
          email,
        }
      );

      // 成功時は更新された詳細を返す
      return res.render('users/_detail.html', {
        user: updatedUser,
      });
    } catch (error) {
      console.error('Error updating user:', error);
      return res
        .status(500)
        .send(
          '<div class="error">更新に失敗しました</div>'
        );
    }
  }
}

更新処理では、バリデーションエラー時にはエラー表示パーシャルを、成功時には更新された詳細パーシャルを返します。

フォームのテンプレート

html<!-- views/users/_form.html -->
<form
  hx-{{
  method|lower
  }}="{{ action }}"
  hx-target="#detail-section"
  hx-swap="innerHTML"
>
  <div class="form-group">
    <label for="name">名前</label>
    <input
      type="text"
      id="name"
      name="name"
      value="{{ user.name }}"
      required
    />
  </div>

  <div class="form-group">
    <label for="email">メール</label>
    <input
      type="email"
      id="email"
      name="email"
      value="{{ user.email }}"
      required
    />
  </div>

  <div class="form-actions">
    <button type="submit">保存</button>
    <button
      type="button"
      hx-get="/partials/users/{{ user.id }}/detail"
      hx-target="#detail-section"
    >
      キャンセル
    </button>
  </div>
</form>

フォームには htmx の属性を設定し、送信時に AJAX で処理されるようにします。成功時には自動的に詳細表示に切り替わります。

エラーハンドリングの実装

エラー時にも適切な HTML パーシャルを返すことで、ユーザー体験を向上させます。

typescript// controllers/users/UserListController.ts(エラーハンドリング追加版)
export class UserListController {
  constructor(private userService: UserService) {}

  async list(req: Request, res: Response) {
    try {
      const {
        search,
        page = '1',
        limit = '20',
      } = req.query;

      const users = await this.userService.searchUsers({
        search: search as string,
        page: parseInt(page as string, 10),
        limit: parseInt(limit as string, 10),
      });

      // 結果が空の場合
      if (users.length === 0) {
        return res.render('users/_empty.html', {
          message: search
            ? '検索結果が見つかりませんでした'
            : 'ユーザーが登録されていません',
        });
      }

      return res.render('users/_list.html', { users });
    } catch (error) {
      console.error('Error loading user list:', error);

      // エラーパーシャルを返す
      return res.status(500).render('users/_error.html', {
        message: 'データの読み込みに失敗しました',
        retry: true,
      });
    }
  }
}

エラー時やデータが空の場合にも、専用のパーシャルテンプレートを用意することで、適切なフィードバックを提供できます。

html<!-- views/users/_error.html -->
<div class="error-message">
  <p>{{ message }}</p>
  {% if retry %}
  <button
    hx-get="/partials/users/list"
    hx-target="#user-list"
  >
    再読み込み
  </button>
  {% endif %}
</div>

エラー表示のパーシャルでは、再試行ボタンも提供し、ユーザーが自力で復旧できるようにします。

ディレクトリ構成のまとめ

最終的なディレクトリ構成は以下のようになります。

textsrc/
├── controllers/
│   └── users/
│       ├── UserPageController.ts       # 完全ページ
│       ├── UserListController.ts       # 一覧パーシャル
│       ├── UserDetailController.ts     # 詳細パーシャル
│       ├── UserFormController.ts       # フォーム表示
│       └── UserUpdateController.ts     # 更新処理
├── services/
│   └── UserService.ts                  # ビジネスロジック
├── routes/
│   └── users.ts                        # ルーティング定義
└── views/
    └── users/
        ├── index.html                  # 完全ページ
        ├── _list.html                  # 一覧パーシャル
        ├── _detail.html                # 詳細パーシャル
        ├── _form.html                  # フォームパーシャル
        ├── _empty.html                 # 空状態パーシャル
        └── _error.html                 # エラーパーシャル

この構成により、各ファイルの責務が明確になり、保守性の高いコードベースを実現できます。

まとめ

htmx を使った BFF 設計では、従来の MVC パターンとは異なるアプローチが求められます。本記事で解説した設計原則をまとめると、以下のようになります。

設計原則

#原則内容
1コントローラの分割機能とレスポンス形式で分割し、責務を明確化
2パーシャルの命名アンダースコアプレフィックスで識別しやすく
3URL 設計​/​partials プレフィックスでエンドポイントを分類
4サービス層の活用ビジネスロジックをコントローラから分離
5エラーハンドリング専用パーシャルで適切なフィードバックを提供

得られるメリット

この設計パターンを採用することで、以下のメリットが得られます。

まず、コードの見通しが良くなり、どのファイルがどの責務を持つか一目で分かるようになります。新しいメンバーがプロジェクトに参加した際も、迷わず必要なコードを見つけられるでしょう。

次に、テストが書きやすくなります。各コントローラが単一の責務に集中しているため、テストケースもシンプルになり、カバレッジの向上にもつながりますね。

さらに、パーシャルの再利用性が高まります。同じパーシャルを初期ロード時と AJAX リクエスト時の両方で使用できるため、コードの重複を避けられます。

最後に、拡張性が向上します。新しい機能を追加する際も、既存のコントローラに手を加えることなく、新しいコントローラとパーシャルを追加するだけで済むのです。

実践のポイント

実際にこの設計を導入する際は、以下の点に注意してください。

最初から完璧な分割を目指す必要はありません。まずは 1 つの機能で試してみて、チームに合った形に調整していくのが良いでしょう。

また、パーシャルの粒度は、画面の更新単位に合わせて決めます。頻繁に更新される部分は独立したパーシャルにし、そうでない部分は大きめにまとめても構いません。

エラーハンドリングは、ユーザー体験に直結する重要な要素です。単にエラーメッセージを表示するだけでなく、再試行ボタンや代替手段を提供することで、より良い UX を実現できます。

htmx と BFF パターンを組み合わせることで、シンプルかつ保守性の高い Web アプリケーションを構築できます。ぜひこの設計パターンを活用して、効率的な開発を進めてください。

関連リンク