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 では、画面の一部分だけを更新する「パーシャル更新」が中心となるため、設計方針を見直す必要があります。
# | 項目 | 従来の MVC | htmx + 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 | パーシャルの命名 | アンダースコアプレフィックスで識別しやすく |
3 | URL 設計 | /partials プレフィックスでエンドポイントを分類 |
4 | サービス層の活用 | ビジネスロジックをコントローラから分離 |
5 | エラーハンドリング | 専用パーシャルで適切なフィードバックを提供 |
得られるメリット
この設計パターンを採用することで、以下のメリットが得られます。
まず、コードの見通しが良くなり、どのファイルがどの責務を持つか一目で分かるようになります。新しいメンバーがプロジェクトに参加した際も、迷わず必要なコードを見つけられるでしょう。
次に、テストが書きやすくなります。各コントローラが単一の責務に集中しているため、テストケースもシンプルになり、カバレッジの向上にもつながりますね。
さらに、パーシャルの再利用性が高まります。同じパーシャルを初期ロード時と AJAX リクエスト時の両方で使用できるため、コードの重複を避けられます。
最後に、拡張性が向上します。新しい機能を追加する際も、既存のコントローラに手を加えることなく、新しいコントローラとパーシャルを追加するだけで済むのです。
実践のポイント
実際にこの設計を導入する際は、以下の点に注意してください。
最初から完璧な分割を目指す必要はありません。まずは 1 つの機能で試してみて、チームに合った形に調整していくのが良いでしょう。
また、パーシャルの粒度は、画面の更新単位に合わせて決めます。頻繁に更新される部分は独立したパーシャルにし、そうでない部分は大きめにまとめても構いません。
エラーハンドリングは、ユーザー体験に直結する重要な要素です。単にエラーメッセージを表示するだけでなく、再試行ボタンや代替手段を提供することで、より良い UX を実現できます。
htmx と BFF パターンを組み合わせることで、シンプルかつ保守性の高い Web アプリケーションを構築できます。ぜひこの設計パターンを活用して、効率的な開発を進めてください。
関連リンク
- article
NestJS クリーンアーキテクチャ:UseCase/Domain/Adapter を疎結合に保つ設計術
- article
WebSocket プロトコル設計:バージョン交渉・機能フラグ・後方互換のパターン
- article
MySQL 読み書き分離設計:ProxySQL で一貫性とスループットを両立
- article
Motion(旧 Framer Motion)アニメオーケストレーション設計:timeline・遅延・相互依存の整理術
- article
WebRTC で遠隔支援:画面注釈・ポインタ共有・低遅延音声の実装事例
- article
JavaScript パフォーマンス最適化大全:レイアウトスラッシングを潰す実践テク
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来