← ブログ一覧

BFF(Backend for Frontend)パターン実践ガイド

モバイル・Web で異なる API を効率的に提供する BFF パターンの設計・実装方法を解説。GraphQL との比較、Next.js Server Actions での実装例、マイクロサービスとの統合まで網羅した実務ガイド。

#アーキテクチャ#API#Next.js#マイクロサービス
BFF(Backend for Frontend)パターン実践ガイド

BFF(Backend for Frontend)パターン実践ガイド

モバイルアプリと Web アプリで求められる API の形は異なります。モバイルは通信量を抑えたい、Web は SEO のためにサーバーサイドで HTML を生成したい。同じバックエンド API をそのまま使うと、クライアント側で複雑な加工処理が必要になり、パフォーマンスと保守性が低下します。

BFF(Backend for Frontend)パターンは、フロントエンド(クライアント)ごとに専用のバックエンド層を用意することで、この課題を解決します。本記事では、BFF の設計判断・実装パターン・運用設計まで、受託開発の現場で即使える実務知見を解説します。


1. BFF パターンとは何か

BFF の基本構造

BFF は、クライアント(モバイル・Web・管理画面など)ごとに専用の API 層を配置するアーキテクチャパターンです。

[Mobile App] → [Mobile BFF] ┐
[Web App]    → [Web BFF]    ├→ [共通 Microservices]
[Admin]      → [Admin BFF]  ┘   (User / Product / Order...)

BFF が解決する課題

| 課題 | BFF なし | BFF あり | |------|----------|----------| | データ形式の不一致 | クライアントが複数 API を呼び出して加工 | BFF が必要な形式で返却 | | 過剰なデータ取得 | 不要なフィールドも含めて返却 | クライアントに必要な項目だけ返却 | | 認証の複雑化 | 各クライアントが個別に認証処理 | BFF で統一的に認証・認可 | | バージョン管理 | 全クライアントで同じ API 仕様 | クライアントごとに独立して変更可能 |

BFF を採用すべきケース

  • 複数のクライアント種別がある(iOS / Android / Web / 管理画面)
  • クライアントごとに求められるデータ形式が異なる
  • モバイルで通信量・バッテリー消費を最適化したい
  • Web で SEO 対応のため SSR が必要
  • マイクロサービス化が進み、クライアント側での API 統合が複雑

2. BFF と GraphQL の比較

BFF を検討する際、GraphQL も候補に挙がります。それぞれの特性を整理します。

比較表

| 観点 | BFF(REST ベース) | GraphQL | |------|-------------------|----------| | 学習コスト | 低い(既存の REST 知識が活用可能) | 中〜高(スキーマ言語・Resolver の理解が必要) | | クライアント自由度 | BFF が返すデータ形式は固定 | クライアントが必要なフィールドを指定可能 | | キャッシュ | HTTP キャッシュが使える | CDN キャッシュが難しい(POST リクエスト) | | N+1 問題 | BFF で事前に対策 | DataLoader で解決が必要 | | 型安全性 | OpenAPI + Codegen で対応可能 | GraphQL Codegen で自動生成 | | 運用負荷 | BFF ごとにコードが独立 | 統一されたスキーマ管理が必要 |

判断基準

  • BFF を選ぶべきケース

    • クライアント種別が 2〜3 種類で、要件が明確に異なる
    • チームに GraphQL の知見がない
    • HTTP キャッシュを積極的に活用したい
    • 段階的な移行を重視
  • GraphQL を選ぶべきケース

    • クライアントが頻繁に必要なデータ形式を変更する
    • モバイルアプリで通信量の最適化が最優先
    • 統一されたスキーマ駆動開発を採用したい

3. BFF の設計パターン

パターン 1: 完全分離型

各 BFF が独立したリポジトリ・デプロイ単位を持つ構成。

リポジトリ:
- mobile-bff/
- web-bff/
- admin-bff/
- services/ (共通マイクロサービス)

メリット

  • 完全に独立した開発サイクル
  • 技術スタックを BFF ごとに選択可能

デメリット

  • 共通ロジックの重複リスク
  • インフラ管理の複雑化

パターン 2: モノレポ+共通モジュール

モノレポで BFF を管理し、共通処理をパッケージ化。

monorepo/
├── packages/
│   ├── auth-client/
│   ├── api-client/
│   └── logging/
└── apps/
    ├── mobile-bff/
    ├── web-bff/
    └── admin-bff/

メリット

  • 共通ロジックの再利用が容易
  • 型定義の共有が簡単

デメリット

  • ビルド時間の増加
  • 依存関係の管理が複雑

パターン 3: Next.js Server Actions(Web BFF)

Next.js の Server Actions を Web BFF として活用。

// app/actions/products.ts
'use server'

import { getAuthUser } from '@/lib/auth'
import { productServiceClient } from '@/lib/api-clients'

export async function getProductsForWeb() {
  const user = await getAuthUser()
  
  // バックエンド API を呼び出し
  const products = await productServiceClient.getProducts({
    userId: user.id,
    includeRecommendations: true,
  })
  
  // Web 向けに整形
  return products.map(p => ({
    id: p.id,
    name: p.name,
    price: p.price,
    imageUrl: p.images[0]?.url,
    // モバイルでは不要な SEO 用メタ情報
    metaDescription: p.description,
    structuredData: generateProductSchema(p),
  }))
}

メリット

  • Next.js の型システムがそのまま使える
  • デプロイが容易(Vercel など)

デメリット

  • Next.js に依存
  • モバイル BFF には別途必要

4. BFF の実装例:Express + TypeScript

ディレクトリ構成

mobile-bff/
├── src/
│   ├── routes/
│   │   ├── products.ts
│   │   └── orders.ts
│   ├── services/
│   │   ├── productService.ts
│   │   └── orderService.ts
│   ├── middleware/
│   │   ├── auth.ts
│   │   └── errorHandler.ts
│   └── types/
│       └── api.ts
└── package.json

基本実装

// src/routes/products.ts
import { Router } from 'express'
import { authenticateUser } from '../middleware/auth'
import { ProductService } from '../services/productService'
import type { MobileProductResponse } from '../types/api'

const router = Router()
const productService = new ProductService()

// モバイル向け商品一覧
router.get('/products', authenticateUser, async (req, res) => {
  const userId = req.user!.id
  
  // 複数のマイクロサービスを統合
  const [products, recommendations, inventory] = await Promise.all([
    productService.getProducts(),
    productService.getRecommendations(userId),
    productService.getInventory(),
  ])
  
  // モバイル向けに最適化したレスポンス
  const response: MobileProductResponse = {
    items: products.map(p => ({
      id: p.id,
      name: p.name,
      // 画像は thumbnail のみ(通信量削減)
      thumbnail: p.images.find(img => img.type === 'thumbnail')?.url,
      price: p.price,
      // 在庫ステータスを統合
      inStock: inventory[p.id] > 0,
    })),
    // レコメンドは ID のみ
    recommendedIds: recommendations.map(r => r.id),
  }
  
  res.json(response)
})

export default router

Service 層での API 統合

// src/services/productService.ts
import { apiClient } from '../lib/apiClient'
import type { Product } from '../types/domain'

export class ProductService {
  async getProducts(): Promise<Product[]> {
    // 商品マイクロサービスを呼び出し
    const response = await apiClient.get('/api/products')
    return response.data
  }
  
  async getRecommendations(userId: string) {
    // レコメンドエンジンを呼び出し
    const response = await apiClient.get(`/api/recommendations/${userId}`)
    return response.data
  }
  
  async getInventory(): Promise<Record<string, number>> {
    // 在庫管理システムを呼び出し
    const response = await apiClient.get('/api/inventory')
    return response.data.reduce((acc, item) => {
      acc[item.productId] = item.quantity
      return acc
    }, {} as Record<string, number>)
  }
}

5. 認証・認可の実装パターン

パターン 1: JWT をそのまま転送

BFF はトークンを検証せず、バックエンドに転送。

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express'

export function forwardAuth(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization
  
  if (!token) {
    return res.status(401).json({ error: 'Unauthorized' })
  }
  
  // バックエンド API クライアントにトークンを設定
  req.apiClient = createApiClient({
    headers: { Authorization: token }
  })
  
  next()
}

メリット: BFF の責務が軽い
デメリット: バックエンドへの不正リクエストを BFF で防げない

パターン 2: BFF でトークン検証

BFF で JWT を検証し、バックエンドには内部トークンを使用。

// src/middleware/auth.ts
import jwt from 'jsonwebtoken'
import { Request, Response, NextFunction } from 'express'

export function authenticateUser(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.replace('Bearer ', '')
  
  if (!token) {
    return res.status(401).json({ error: 'Unauthorized' })
  }
  
  try {
    // JWT を検証
    const decoded = jwt.verify(token, process.env.JWT_PUBLIC_KEY!)
    req.user = decoded as { id: string; role: string }
    
    // バックエンド向けに内部サービストークンを使用
    req.apiClient = createApiClient({
      headers: { 'X-Internal-Token': process.env.SERVICE_TOKEN }
    })
    
    next()
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' })
  }
}

メリット: 不正リクエストを BFF で遮断
デメリット: BFF で認証ロジックが重複


6. エラーハンドリングとロギング

統一的なエラーレスポンス

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
import { logger } from '../lib/logger'

export class ApiError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public code?: string
  ) {
    super(message)
  }
}

export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  if (err instanceof ApiError) {
    // 意図したエラー
    logger.warn({
      message: err.message,
      code: err.code,
      path: req.path,
      userId: req.user?.id,
    })
    
    return res.status(err.statusCode).json({
      error: {
        message: err.message,
        code: err.code,
      }
    })
  }
  
  // 予期しないエラー
  logger.error({
    message: err.message,
    stack: err.stack,
    path: req.path,
    userId: req.user?.id,
  })
  
  return res.status(500).json({
    error: {
      message: 'Internal server error',
      code: 'INTERNAL_ERROR',
    }
  })
}

構造化ログの実装

// src/lib/logger.ts
import pino from 'pino'

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport: {
    target: 'pino-pretty',
    options: {
      colorize: true,
      ignore: 'pid,hostname',
      translateTime: 'SYS:standard',
    }
  }
})

// リクエストログミドルウェア
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const start = Date.now()
  
  res.on('finish', () => {
    logger.info({
      method: req.method,
      path: req.path,
      statusCode: res.statusCode,
      duration: Date.now() - start,
      userId: req.user?.id,
    })
  })
  
  next()
}

7. パフォーマンス最適化

並列リクエストの実装

// 悪い例:直列処理
async function getProductDetail(productId: string) {
  const product = await productService.getProduct(productId)
  const reviews = await reviewService.getReviews(productId)
  const related = await productService.getRelated(productId)
  
  return { product, reviews, related }
}

// 良い例:並列処理
async function getProductDetail(productId: string) {
  const [product, reviews, related] = await Promise.all([
    productService.getProduct(productId),
    reviewService.getReviews(productId),
    productService.getRelated(productId),
  ])
  
  return { product, reviews, related }
}

レスポンスキャッシュ

// src/middleware/cache.ts
import { createClient } from 'redis'
import { Request, Response, NextFunction } from 'express'

const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

export function cacheMiddleware(ttl: number) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = `cache:${req.path}:${JSON.stringify(req.query)}`
    
    // キャッシュをチェック
    const cached = await redis.get(key)
    if (cached) {
      return res.json(JSON.parse(cached))
    }
    
    // オリジナルの res.json を上書き
    const originalJson = res.json.bind(res)
    res.json = (body: any) => {
      // キャッシュに保存
      redis.setEx(key, ttl, JSON.stringify(body))
      return originalJson(body)
    }
    
    next()
  }
}

// 使用例
router.get('/products', cacheMiddleware(300), async (req, res) => {
  // 5分間キャッシュされる
})

8. 運用・監視の実装

ヘルスチェック

// src/routes/health.ts
import { Router } from 'express'
import { apiClient } from '../lib/apiClient'

const router = Router()

router.get('/health', async (req, res) => {
  const health = {
    status: 'ok',
    timestamp: new Date().toISOString(),
    dependencies: {} as Record<string, string>,
  }
  
  // 依存サービスのヘルスチェック
  try {
    await apiClient.get('/health', { timeout: 1000 })
    health.dependencies.productService = 'ok'
  } catch (err) {
    health.dependencies.productService = 'error'
    health.status = 'degraded'
  }
  
  const statusCode = health.status === 'ok' ? 200 : 503
  res.status(statusCode).json(health)
})

export default router

メトリクス収集

// src/lib/metrics.ts
import { Counter, Histogram, Registry } from 'prom-client'

const register = new Registry()

export const httpRequestDuration = new Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status_code'],
  registers: [register],
})

export const httpRequestTotal = new Counter({
  name: 'http_requests_total',
  help: 'Total number of HTTP requests',
  labelNames: ['method', 'route', 'status_code'],
  registers: [register],
})

// メトリクス公開エンドポイント
router.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType)
  res.end(await register.metrics())
})

9. デプロイとインフラ構成

Docker Compose での開発環境

# docker-compose.yml
version: '3.8'

services:
  mobile-bff:
    build: ./mobile-bff
    ports:
      - "3001:3000"
    environment:
      - NODE_ENV=development
      - PRODUCT_SERVICE_URL=http://product-service:3000
      - REDIS_URL=redis://redis:6379
    depends_on:
      - redis
      - product-service
  
  web-bff:
    build: ./web-bff
    ports:
      - "3002:3000"
    environment:
      - NODE_ENV=development
      - PRODUCT_SERVICE_URL=http://product-service:3000
    depends_on:
      - product-service
  
  product-service:
    build: ./services/product
    ports:
      - "4000:3000"
  
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

Kubernetes デプロイ例

# k8s/mobile-bff-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mobile-bff
spec:
  replicas: 3
  selector:
    matchLabels:
      app: mobile-bff
  template:
    metadata:
      labels:
        app: mobile-bff
    spec:
      containers:
      - name: mobile-bff
        image: registry.example.com/mobile-bff:latest
        ports:
        - containerPort: 3000
        env:
        - name: NODE_ENV
          value: "production"
        - name: PRODUCT_SERVICE_URL
          valueFrom:
            configMapKeyRef:
              name: bff-config
              key: product-service-url
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 5

10. 実務 Tips とトラブルシューティング

よくある問題と対処法

| 問題 | 原因 | 対処法 | |------|------|--------| | BFF のレスポンスが遅い | 複数サービスへの直列呼び出し | Promise.all で並列化 | | BFF で共通処理が重複 | 各 BFF で個別実装 | 共通ライブラリを npm パッケージ化 | | バックエンドの変更が BFF に波及 | 密結合 | API Gateway パターンで疎結合化 | | 認証トークンの期限切れ | リフレッシュトークン未対応 | BFF でリフレッシュ処理を実装 | | BFF が単一障害点に | 冗長化不足 | 複数インスタンス + ロードバランサー |

チーム体制の設計

  • 小規模(〜5人): モノレポで全 BFF を管理
  • 中規模(5〜15人): BFF ごとにオーナーを決め、共通基盤チームでライブラリ提供
  • 大規模(15人〜): BFF を完全分離し、各チームが独立して開発

段階的な導入戦略

  1. フェーズ 1: 既存 API の前に Web BFF だけ配置
  2. フェーズ 2: モバイル BFF を追加し、通信量最適化
  3. フェーズ 3: バックエンドをマイクロサービス化し、BFF で統合

まとめ

BFF パターンは、複数のクライアント種別を持つアプリケーションで、それぞれに最適化された API を提供するための実用的なアーキテクチャです。

導入の判断基準

  • クライアントが 2 種類以上あり、要件が明確に異なる
  • モバイルで通信量の最適化が必要
  • マイクロサービス化が進み、クライアント側での API 統合が複雑

実装のポイント

  • 並列処理で複数サービスへのリクエストを最適化
  • 共通処理はライブラリ化して重複を避ける
  • キャッシュとロギングで運用性を高める

受託開発では、プロジェクトの規模とチーム体制に応じて、モノレポ構成から完全分離まで柔軟に選択できます。まずは Web BFF だけ導入し、効果を確認してから段階的に拡大するアプローチが現実的です。


Yureate では、BFF を含むバックエンドアーキテクチャ設計から実装・運用まで一貫して支援しています。既存システムへの BFF 導入や、マイクロサービス移行の相談もお気軽にお問い合わせください。

この内容について相談する他の記事を見る