T-CREATOR

tRPC アーキテクチャ設計:BFF とドメイン分割で肥大化を防ぐルータ戦略

tRPC アーキテクチャ設計:BFF とドメイン分割で肥大化を防ぐルータ戦略

tRPC を使った開発を進めていくと、必ずと言っていいほど直面するのが「ルータの肥大化」という課題です。最初は小さく始めたプロジェクトも、機能が増えるにつれてルータファイルが数百行、数千行と膨れ上がり、保守性が大きく損なわれてしまいます。

本記事では、tRPC のアーキテクチャ設計における実践的なアプローチとして、BFF(Backend For Frontend)パターンとドメイン分割戦略を組み合わせた設計手法を解説します。これにより、スケーラブルで保守性の高い tRPC アプリケーションを構築できるでしょう。

背景

tRPC におけるルータの役割

tRPC では、すべての API エンドポイントを「ルータ」という単位で管理します。ルータは Procedure(プロシージャ)と呼ばれる個別の処理をまとめたもので、型安全性を保ちながらフロントエンドとバックエンドを接続する重要な役割を果たしています。

typescript// 基本的なルータの例
import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

// ルータの定義
export const appRouter = t.router({
  // Procedure: ユーザー取得
  getUser: t.procedure.query(async ({ input }) => {
    return { id: 1, name: '太郎' };
  }),
  // Procedure: ユーザー作成
  createUser: t.procedure.mutation(async ({ input }) => {
    return { id: 2, name: '花子' };
  }),
});

このシンプルな構造が、tRPC の強みである型安全性とエンドツーエンドの型推論を実現しています。

スケールに伴う課題の発生

しかし、アプリケーションが成長すると、1 つのルータファイルに数十、数百の Procedure が集中してしまうことがあります。

typescript// 肥大化したルータの例(アンチパターン)
export const appRouter = t.router({
  // ユーザー関連
  getUser: t.procedure.query(/* ... */),
  createUser: t.procedure.mutation(/* ... */),
  updateUser: t.procedure.mutation(/* ... */),
  deleteUser: t.procedure.mutation(/* ... */),

  // 商品関連
  getProduct: t.procedure.query(/* ... */),
  createProduct: t.procedure.mutation(/* ... */),
  updateProduct: t.procedure.mutation(/* ... */),

  // 注文関連
  getOrder: t.procedure.query(/* ... */),
  createOrder: t.procedure.mutation(/* ... */),

  // ... さらに続く ...
});

このような状態では、コードの可読性が低下し、チーム開発での競合が発生しやすくなります。また、特定の機能を探すだけでも時間がかかってしまうでしょう。

以下の図は、肥大化したルータの構造を示しています。

mermaidflowchart TD
  client["フロントエンド<br/>クライアント"]
  router["appRouter<br/>(肥大化した巨大ルータ)"]
  user["ユーザー関連<br/>Procedure群"]
  product["商品関連<br/>Procedure群"]
  order["注文関連<br/>Procedure群"]
  payment["決済関連<br/>Procedure群"]
  notification["通知関連<br/>Procedure群"]

  client -->|全ての呼び出し| router
  router --> user
  router --> product
  router --> order
  router --> payment
  router --> notification

すべてのドメインロジックが 1 つのルータに集約されているため、保守性・テスタビリティ・再利用性が大きく損なわれています。

課題

ルータ肥大化がもたらす具体的な問題

tRPC のルータが肥大化すると、以下のような深刻な問題が発生します。

1. 可読性とメンテナンス性の低下

1 つのファイルに数百行のコードが集中すると、どこに何があるのか把握するのが困難になります。新しいメンバーがプロジェクトに参加した際、コードベースを理解するまでに多くの時間を要するでしょう。

typescript// 1000行を超えるルータファイル(例)
export const appRouter = t.router({
  // 100個以上のProcedureが並ぶ...
  getUserById: t.procedure /* 省略 */,
  getUserByEmail: t.procedure /* 省略 */,
  getUsersByRole: t.procedure /* 省略 */,
  // ... 延々と続く ...
});

2. チーム開発での Git 競合

複数の開発者が同じルータファイルを編集すると、Git のマージ競合が頻発します。特に、機能追加が並行して進む場合、この問題は深刻化するでしょう。

3. テストの複雑化

巨大なルータは単体テストが書きにくく、テストコード自体も肥大化します。特定の Procedure だけをテストしたい場合でも、ルータ全体の依存関係を考慮しなければなりません。

4. 責任範囲の不明確化

ドメインロジックが混在すると、「この Procedure はどのビジネス機能に関連しているのか」が不明確になります。結果として、影響範囲の把握が難しくなり、バグの温床となってしまいます。

以下の図は、ルータ肥大化によって発生する問題の連鎖を示しています。

mermaidflowchart LR
  problem1["ルータの<br/>肥大化"]
  problem2["可読性<br/>低下"]
  problem3["Git競合<br/>頻発"]
  problem4["テスト<br/>困難"]
  problem5["バグ<br/>増加"]
  problem6["開発速度<br/>低下"]

  problem1 --> problem2
  problem1 --> problem3
  problem2 --> problem4
  problem4 --> problem5
  problem3 --> problem6
  problem5 --> problem6

これらの問題を放置すると、最終的にはプロジェクト全体の開発速度が大きく低下してしまいます。

解決策

BFF パターンとドメイン分割による設計戦略

ルータの肥大化を防ぐには、BFF(Backend For Frontend)パターンドメイン駆動設計の考え方を組み合わせることが効果的です。

設計の 3 つの柱

#設計原則説明メリット
1ドメイン分割ビジネスドメインごとにルータを分離する責任範囲が明確になり、保守性が向上
2BFF レイヤーフロントエンド向けに最適化された API 層を設けるクライアントのニーズに柔軟に対応可能
3階層化アーキテクチャルータ・サービス・リポジトリを分離するテスタビリティと再利用性が向上

アーキテクチャの全体像

以下の図は、BFF パターンとドメイン分割を適用した tRPC アーキテクチャの全体像を示しています。

mermaidflowchart TD
  client["フロントエンド<br/>クライアント"]

  subgraph bff["BFFレイヤー(tRPCルータ群)"]
    userRouter["userRouter"]
    productRouter["productRouter"]
    orderRouter["orderRouter"]
  end

  subgraph service["サービスレイヤー"]
    userService["UserService"]
    productService["ProductService"]
    orderService["OrderService"]
  end

  subgraph repo["リポジトリレイヤー"]
    userRepo["UserRepository"]
    productRepo["ProductRepository"]
    orderRepo["OrderRepository"]
  end

  db[("データベース")]

  client -->|型安全な呼び出し| bff
  userRouter --> userService
  productRouter --> productService
  orderRouter --> orderService

  userService --> userRepo
  productService --> productRepo
  orderService --> orderRepo

  userRepo --> db
  productRepo --> db
  orderRepo --> db

この設計により、各レイヤーの責任が明確になり、変更の影響範囲を最小限に抑えられます。

ドメイン別ルータ分割の実装

まずは、ドメインごとにルータを分割する方法を見ていきましょう。

ディレクトリ構成

bashsrc/
├── server/
│   ├── routers/           # ドメイン別ルータ
│   │   ├── user.ts        # ユーザードメイン
│   │   ├── product.ts     # 商品ドメイン
│   │   └── order.ts       # 注文ドメイン
│   ├── services/          # ビジネスロジック層
│   ├── repositories/      # データアクセス層
│   ├── trpc.ts           # tRPC初期化
│   └── index.ts          # ルートルータ

tRPC の初期化

tRPC の初期化では、Context の型定義を行います。Context には認証情報やデータベース接続などを含めることができます。

typescript// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';

// Context の型定義
export const createTRPCContext = async (
  opts: CreateNextContextOptions
) => {
  return {
    // セッション情報などを含める
    session: await getSession(opts),
    // データベース接続
    prisma,
  };
};

export type Context = Awaited<
  ReturnType<typeof createTRPCContext>
>;

Context は各 Procedure で ctx として参照でき、認証やデータベースアクセスに利用します。

typescript// tRPCインスタンスの作成
const t = initTRPC.context<Context>().create();

// 再利用可能なエクスポート
export const router = t.router;
export const publicProcedure = t.procedure;

publicProcedure は認証不要の Procedure で、後ほどミドルウェアを追加することで認証が必要な protectedProcedure なども作成できます。

ユーザードメインルータ

ユーザー関連の機能をまとめたルータを作成します。

typescript// src/server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
import { UserService } from '../services/UserService';

依存関係として、バリデーションライブラリの Zod と、ビジネスロジックを担当する UserService をインポートします。

typescript// ユーザー取得のProcedure
const getUser = publicProcedure
  .input(
    z.object({
      id: z.string(),
    })
  )
  .query(async ({ input, ctx }) => {
    // サービス層に処理を委譲
    const userService = new UserService(ctx.prisma);
    return await userService.getUserById(input.id);
  });

input メソッドで Zod スキーマを使った入力バリデーションを定義します。これにより、フロントエンドからの不正なデータを事前に防げるでしょう。

typescript// ユーザー作成のProcedure
const createUser = publicProcedure
  .input(
    z.object({
      name: z.string().min(1).max(100),
      email: z.string().email(),
    })
  )
  .mutation(async ({ input, ctx }) => {
    const userService = new UserService(ctx.prisma);
    return await userService.createUser(input);
  });

mutation は、データの作成・更新・削除など、副作用を伴う処理に使用します。query との明確な使い分けが重要です。

typescript// ユーザードメインルータのエクスポート
export const userRouter = router({
  getUser,
  createUser,
  // 他のユーザー関連Procedureを追加
  updateUser: publicProcedure /* 省略 */,
  deleteUser: publicProcedure /* 省略 */,
});

ルータには複数の Procedure をオブジェクト形式でまとめます。これにより、trpc.user.getUser のような階層的な呼び出しが可能になります。

商品ドメインルータ

商品に関する機能も同様の構造で分離します。

typescript// src/server/routers/product.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
import { ProductService } from '../services/ProductService';
typescript// 商品一覧取得
const listProducts = publicProcedure
  .input(
    z.object({
      page: z.number().min(1).default(1),
      limit: z.number().min(1).max(100).default(20),
    })
  )
  .query(async ({ input, ctx }) => {
    const productService = new ProductService(ctx.prisma);
    return await productService.listProducts(
      input.page,
      input.limit
    );
  });

ページネーション機能を持つ一覧取得の例です。デフォルト値を設定することで、フロントエンド側での実装を簡素化できます。

typescript// 商品作成
const createProduct = publicProcedure
  .input(
    z.object({
      name: z.string().min(1).max(200),
      price: z.number().min(0),
      description: z.string().optional(),
    })
  )
  .mutation(async ({ input, ctx }) => {
    const productService = new ProductService(ctx.prisma);
    return await productService.createProduct(input);
  });
typescript// 商品ドメインルータ
export const productRouter = router({
  listProducts,
  createProduct,
  getProduct: publicProcedure /* 省略 */,
  updateProduct: publicProcedure /* 省略 */,
  deleteProduct: publicProcedure /* 省略 */,
});

ルートルータでの統合

分割したドメインルータを 1 つのルートルータに統合します。

typescript// src/server/index.ts
import { router } from './trpc';
import { userRouter } from './routers/user';
import { productRouter } from './routers/product';
import { orderRouter } from './routers/order';
typescript// ルートルータの作成
export const appRouter = router({
  user: userRouter,
  product: productRouter,
  order: orderRouter,
});

// 型エクスポート(フロントエンドで使用)
export type AppRouter = typeof appRouter;

AppRouter 型をエクスポートすることで、フロントエンド側で完全な型安全性を享受できます。この型情報により、存在しない Procedure の呼び出しや、誤った入力値を静的に検出できるでしょう。

サービス層の実装

ルータからビジネスロジックを分離し、サービス層に切り出すことで、テスタビリティと再利用性が向上します。

UserService の実装

typescript// src/server/services/UserService.ts
import type { PrismaClient } from "@prisma/client";

export class UserService {
  constructor(private prisma: PrismaClient) {}

  // ユーザー取得
  async getUserById(id: string) {
    const user = await this.prisma.user.findUnique({
      where: { id },
    });

    if (!user) {
      throw new Error("User not found");
    }

    return user;
  }

コンストラクタで Prisma クライアントを受け取ることで、依存性の注入(DI)パターンを実現しています。これにより、テスト時にモックオブジェクトを注入できます。

typescript  // ユーザー作成
  async createUser(data: { name: string; email: string }) {
    // メールアドレスの重複チェック
    const existingUser = await this.prisma.user.findUnique({
      where: { email: data.email },
    });

    if (existingUser) {
      throw new Error("Email already exists");
    }

    // ユーザー作成
    return await this.prisma.user.create({
      data: {
        name: data.name,
        email: data.email,
      },
    });
  }
}

ビジネスロジック(メールアドレスの重複チェックなど)をサービス層に集約することで、ルータはシンプルに保たれます。

リポジトリ層の実装(オプション)

さらに高度な設計として、データアクセスをリポジトリ層に分離することもできます。

typescript// src/server/repositories/UserRepository.ts
import type { PrismaClient, User } from '@prisma/client';

export class UserRepository {
  constructor(private prisma: PrismaClient) {}

  async findById(id: string): Promise<User | null> {
    return await this.prisma.user.findUnique({
      where: { id },
    });
  }

  async findByEmail(email: string): Promise<User | null> {
    return await this.prisma.user.findUnique({
      where: { email },
    });
  }

  async create(data: {
    name: string;
    email: string;
  }): Promise<User> {
    return await this.prisma.user.create({ data });
  }
}

リポジトリパターンを導入すると、データアクセスの詳細がカプセル化され、将来的なデータソースの変更(例: Prisma から別の ORM への移行)にも柔軟に対応できます。

typescript// サービス層からリポジトリを利用
export class UserService {
  private userRepo: UserRepository;

  constructor(prisma: PrismaClient) {
    this.userRepo = new UserRepository(prisma);
  }

  async getUserById(id: string) {
    const user = await this.userRepo.findById(id);
    if (!user) {
      throw new Error('User not found');
    }
    return user;
  }
}

BFF パターンの適用

BFF(Backend For Frontend)パターンでは、フロントエンドのニーズに特化した API を提供します。

マルチプラットフォーム対応の例

Web アプリと管理画面で異なる API を提供する場合を考えてみましょう。

typescript// src/server/routers/web/user.ts
// Webアプリ向け: 公開情報のみ返す
export const webUserRouter = router({
  getProfile: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      const user = await userService.getUserById(input.id);

      // 公開情報のみ返す
      return {
        id: user.id,
        name: user.name,
        avatarUrl: user.avatarUrl,
      };
    }),
});

Web アプリ向けには、セキュリティの観点から必要最小限の情報のみを返します。

typescript// src/server/routers/admin/user.ts
// 管理画面向け: すべての情報を返す
export const adminUserRouter = router({
  getUser: protectedProcedure // 認証必須
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      // 管理者権限チェック
      if (!ctx.session?.user?.isAdmin) {
        throw new Error('Unauthorized');
      }

      // すべての情報を返す
      return await userService.getUserById(input.id);
    }),
});

管理画面向けには、認証とロールチェックを行った上で、詳細情報を返します。

typescript// src/server/index.ts
// プラットフォーム別にルータを分離
export const appRouter = router({
  web: router({
    user: webUserRouter,
    product: webProductRouter,
  }),
  admin: router({
    user: adminUserRouter,
    product: adminProductRouter,
  }),
});

このような構造により、フロントエンドは trpc.web.user.getProfile または trpc.admin.user.getUser のように、目的に応じた API を明確に使い分けられます。

具体例

実際のプロジェクトでの適用例

ここでは、EC サイトを例に、具体的な実装パターンを見ていきましょう。

プロジェクト全体の構成

inisrc/
├── server/
│   ├── routers/
│   │   ├── web/              # Webアプリ向けBFF
│   │   │   ├── user.ts
│   │   │   ├── product.ts
│   │   │   └── cart.ts
│   │   ├── admin/            # 管理画面向けBFF
│   │   │   ├── user.ts
│   │   │   ├── product.ts
│   │   │   └── order.ts
│   │   └── shared/           # 共通ルータ
│   │       └── auth.ts
│   ├── services/
│   │   ├── UserService.ts
│   │   ├── ProductService.ts
│   │   ├── CartService.ts
│   │   └── OrderService.ts
│   ├── repositories/
│   │   ├── UserRepository.ts
│   │   ├── ProductRepository.ts
│   │   └── OrderRepository.ts
│   ├── middleware/
│   │   └── auth.ts
│   ├── trpc.ts
│   └── index.ts
├── pages/
│   └── api/
│       └── trpc/
│           └── [...trpc].ts

この構造により、各レイヤーの責任範囲が明確になります。

認証ミドルウェアの実装

まず、認証が必要な Procedure で使用するミドルウェアを作成します。

typescript// src/server/middleware/auth.ts
import { TRPCError } from '@trpc/server';
import { publicProcedure } from '../trpc';

// 認証ミドルウェア
const isAuthed = publicProcedure.use(
  async ({ ctx, next }) => {
    // セッションチェック
    if (!ctx.session?.user) {
      throw new TRPCError({
        code: 'UNAUTHORIZED',
        message: 'ログインが必要です',
      });
    }

    // 認証済みユーザー情報をContextに追加
    return next({
      ctx: {
        ...ctx,
        user: ctx.session.user,
      },
    });
  }
);

ミドルウェアでは、use メソッドを使って処理を挟み込みます。認証が成功した場合のみ next() を呼び出すことで、Procedure の実行を継続させます。

typescript// 認証必須のProcedureを作成
export const protectedProcedure = isAuthed;

// 管理者権限チェックミドルウェア
export const adminProcedure = protectedProcedure.use(
  async ({ ctx, next }) => {
    if (!ctx.user.isAdmin) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: '管理者権限が必要です',
      });
    }

    return next({ ctx });
  }
);

ミドルウェアは連鎖させることができるため、protectedProcedure の上に adminProcedure を重ねることで、段階的な権限チェックを実現できます。

ショッピングカート機能の実装

Web アプリ向けのカート機能を実装します。

カートサービス

typescript// src/server/services/CartService.ts
import type { PrismaClient } from "@prisma/client";

export class CartService {
  constructor(private prisma: PrismaClient) {}

  // カートに商品を追加
  async addItem(userId: string, productId: string, quantity: number) {
    // 商品の存在と在庫チェック
    const product = await this.prisma.product.findUnique({
      where: { id: productId },
    });

    if (!product) {
      throw new Error("商品が見つかりません");
    }

    if (product.stock < quantity) {
      throw new Error("在庫が不足しています");
    }

ビジネスルール(在庫チェックなど)をサービス層に集約することで、ルータはシンプルに保たれます。

typescript// 既存のカートアイテムをチェック
const existingItem = await this.prisma.cartItem.findFirst({
  where: {
    userId,
    productId,
  },
});

if (existingItem) {
  // 既に存在する場合は数量を更新
  return await this.prisma.cartItem.update({
    where: { id: existingItem.id },
    data: {
      quantity: existingItem.quantity + quantity,
    },
  });
}
typescript    // 新規追加
    return await this.prisma.cartItem.create({
      data: {
        userId,
        productId,
        quantity,
      },
    });
  }
typescript  // カート内容を取得
  async getCartItems(userId: string) {
    return await this.prisma.cartItem.findMany({
      where: { userId },
      include: {
        product: true, // 商品情報も含める
      },
    });
  }
}

カートルータ

typescript// src/server/routers/web/cart.ts
import { z } from 'zod';
import { router, protectedProcedure } from '../../trpc';
import { CartService } from '../../services/CartService';
typescript// カートに商品追加
const addItem = protectedProcedure
  .input(
    z.object({
      productId: z.string(),
      quantity: z.number().min(1).max(99),
    })
  )
  .mutation(async ({ input, ctx }) => {
    const cartService = new CartService(ctx.prisma);

    return await cartService.addItem(
      ctx.user.id, // 認証済みユーザーID
      input.productId,
      input.quantity
    );
  });

protectedProcedure を使うことで、認証済みユーザーのみがカート操作を実行できるようになります。

typescript// カート内容取得
const getItems = protectedProcedure.query(
  async ({ ctx }) => {
    const cartService = new CartService(ctx.prisma);
    return await cartService.getCartItems(ctx.user.id);
  }
);
typescript// カートルータのエクスポート
export const cartRouter = router({
  addItem,
  getItems,
  removeItem: protectedProcedure /* 省略 */,
  updateQuantity: protectedProcedure /* 省略 */,
  clear: protectedProcedure /* 省略 */,
});

管理画面向けの注文管理機能

管理者向けには、より詳細な注文管理機能を提供します。

注文サービス

typescript// src/server/services/OrderService.ts
import type { PrismaClient } from "@prisma/client";

export class OrderService {
  constructor(private prisma: PrismaClient) {}

  // 注文一覧取得(管理者向け)
  async listOrders(filters: {
    page: number;
    limit: number;
    status?: string;
    userId?: string;
  }) {
    const { page, limit, status, userId } = filters;

    const where = {
      ...(status && { status }),
      ...(userId && { userId }),
    };

フィルター条件を動的に構築することで、柔軟な検索機能を実現しています。

typescript    const [orders, total] = await Promise.all([
      this.prisma.order.findMany({
        where,
        skip: (page - 1) * limit,
        take: limit,
        include: {
          user: true,
          items: {
            include: {
              product: true,
            },
          },
        },
        orderBy: {
          createdAt: "desc",
        },
      }),
      this.prisma.order.count({ where }),
    ]);

    return {
      orders,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  }

注文一覧と件数を並行して取得することで、パフォーマンスを向上させています。

typescript  // 注文ステータス更新
  async updateOrderStatus(orderId: string, status: string) {
    return await this.prisma.order.update({
      where: { id: orderId },
      data: { status },
    });
  }
}

管理者向け注文ルータ

typescript// src/server/routers/admin/order.ts
import { z } from 'zod';
import { router, adminProcedure } from '../../trpc';
import { OrderService } from '../../services/OrderService';
typescript// 注文一覧取得
const listOrders = adminProcedure
  .input(
    z.object({
      page: z.number().min(1).default(1),
      limit: z.number().min(1).max(100).default(20),
      status: z.string().optional(),
      userId: z.string().optional(),
    })
  )
  .query(async ({ input, ctx }) => {
    const orderService = new OrderService(ctx.prisma);
    return await orderService.listOrders(input);
  });

管理者のみが実行できる Procedure として adminProcedure を使用します。

typescript// 注文ステータス更新
const updateStatus = adminProcedure
  .input(
    z.object({
      orderId: z.string(),
      status: z.enum([
        'pending',
        'processing',
        'shipped',
        'delivered',
        'cancelled',
      ]),
    })
  )
  .mutation(async ({ input, ctx }) => {
    const orderService = new OrderService(ctx.prisma);
    return await orderService.updateOrderStatus(
      input.orderId,
      input.status
    );
  });

Zod の enum を使うことで、許可されたステータスのみを受け付けるようになります。これにより、不正な値の入力を静的に防げるでしょう。

typescriptexport const adminOrderRouter = router({
  listOrders,
  updateStatus,
  getOrderDetail: adminProcedure /* 省略 */,
  exportOrders: adminProcedure /* 省略 */,
});

フロントエンドでの使用例

最後に、フロントエンド側での tRPC クライアントの使い方を見ていきます。

tRPC クライアントのセットアップ

typescript// src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server';

// tRPCクライアントの作成
export const trpc = createTRPCReact<AppRouter>();

AppRouter 型をインポートすることで、完全な型安全性が確保されます。

React コンポーネントでの使用

typescript// src/components/CartButton.tsx
import { trpc } from '../lib/trpc';

export const CartButton = ({
  productId,
}: {
  productId: string;
}) => {
  // カートに追加するMutation
  const addToCart = trpc.web.cart.addItem.useMutation({
    onSuccess: () => {
      alert('カートに追加しました');
    },
    onError: (error) => {
      alert(`エラー: ${error.message}`);
    },
  });

  const handleAddToCart = () => {
    addToCart.mutate({
      productId,
      quantity: 1,
    });
  };

  return (
    <button
      onClick={handleAddToCart}
      disabled={addToCart.isLoading}
    >
      {addToCart.isLoading ? '追加中...' : 'カートに追加'}
    </button>
  );
};

tRPC の React Hooks を使うことで、ローディング状態やエラーハンドリングを簡潔に実装できます。また、TypeScript の型推論により、入力値の型チェックが自動的に行われるでしょう。

typescript// src/components/CartList.tsx
export const CartList = () => {
  // カート内容を取得
  const { data: cartItems, isLoading } =
    trpc.web.cart.getItems.useQuery();

  if (isLoading) return <div>読み込み中...</div>;

  return (
    <ul>
      {cartItems?.map((item) => (
        <li key={item.id}>
          {item.product.name} - 数量: {item.quantity}
        </li>
      ))}
    </ul>
  );
};

以下の図は、フロントエンドからバックエンドまでのデータフローを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Component as React<br/>コンポーネント
  participant TRPC as tRPCクライアント
  participant Router as tRPCルータ
  participant Service as サービス層
  participant DB as データベース

  User->>Component: カートに追加ボタンクリック
  Component->>TRPC: addToCart.mutate({...})
  TRPC->>Router: web.cart.addItem
  Router->>Service: CartService.addItem()
  Service->>DB: 在庫チェック
  DB-->>Service: 在庫情報
  Service->>DB: カートアイテム作成
  DB-->>Service: 作成結果
  Service-->>Router: 作成されたアイテム
  Router-->>TRPC: レスポンス(型安全)
  TRPC-->>Component: onSuccess呼び出し
  Component-->>User: 「追加しました」表示

この一連のフローにおいて、型安全性が保たれているため、実行時エラーのリスクが大幅に軽減されます。

テスト実装例

階層化されたアーキテクチャは、テストが書きやすいという利点もあります。

typescript// src/server/services/__tests__/CartService.test.ts
import { CartService } from '../CartService';
import { mockPrisma } from '../../test/mockPrisma';

describe('CartService', () => {
  let cartService: CartService;

  beforeEach(() => {
    cartService = new CartService(mockPrisma);
  });

  test('商品をカートに追加できる', async () => {
    // モックデータの設定
    mockPrisma.product.findUnique.mockResolvedValue({
      id: 'product-1',
      stock: 10,
      /* 他のフィールド */
    });

    mockPrisma.cartItem.create.mockResolvedValue({
      id: 'cart-1',
      userId: 'user-1',
      productId: 'product-1',
      quantity: 2,
    });

    // テスト実行
    const result = await cartService.addItem(
      'user-1',
      'product-1',
      2
    );

    // アサーション
    expect(result.quantity).toBe(2);
    expect(
      mockPrisma.cartItem.create
    ).toHaveBeenCalledTimes(1);
  });
});

サービス層が独立しているため、Prisma クライアントをモック化するだけで単体テストを実行できます。

まとめ

設計戦略のポイント

本記事では、tRPC のアーキテクチャ設計における実践的なアプローチを解説しました。重要なポイントを振り返りましょう。

ドメイン分割の効果

ビジネスドメインごとにルータを分離することで、以下のメリットが得られます。

  • 可読性の向上: 関連する機能がまとまり、コードの把握が容易になる
  • Git 競合の削減: 複数の開発者が異なるドメインを並行開発できる
  • 責任範囲の明確化: バグの影響範囲を特定しやすくなる
  • 再利用性: ドメインロジックを他のプロジェクトでも活用できる

BFF パターンの活用

フロントエンド向けに最適化された API 層を設けることで、クライアントのニーズに柔軟に対応できます。

  • プラットフォーム対応: Web アプリと管理画面で異なる API を提供
  • セキュリティ: 必要な情報のみを返すことでデータ漏洩を防ぐ
  • パフォーマンス: クライアントに最適化されたデータ形式を提供

階層化アーキテクチャ

ルータ・サービス・リポジトリの 3 層構造により、保守性と拡張性が大きく向上します。

#レイヤー責任範囲メリット
1ルータ層入力バリデーション、認証チェック型安全性の確保
2サービス層ビジネスロジック、ドメイン知識テスタビリティの向上
3リポジトリ層データアクセス、永続化データソースの抽象化

アンチパターンを避ける

以下のアンチパターンは避けましょう。

  • 巨大なルータファイル: すべての Procedure を 1 つのファイルに詰め込む
  • ルータへのロジック混入: ビジネスロジックをルータに直接記述する
  • 型安全性の放棄: any 型を多用して型チェックを無効化する
  • 過度な抽象化: 不要な層を追加して複雑性を増す

スケールする設計への道

tRPC は型安全性という強力な武器を提供してくれますが、それを最大限に活かすには適切なアーキテクチャ設計が不可欠です。

本記事で紹介した設計パターンを導入することで、数百、数千の Procedure を持つ大規模プロジェクトでも、保守性と開発効率を維持できるでしょう。最初は小さく始めて、プロジェクトの成長に合わせて段階的にリファクタリングしていくことをお勧めします。

あなたのプロジェクトでも、ぜひこの設計手法を試してみてください。型安全でスケーラブルな tRPC アプリケーションの構築に、きっと役立つはずです。

関連リンク