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 | ドメイン分割 | ビジネスドメインごとにルータを分離する | 責任範囲が明確になり、保守性が向上 |
| 2 | BFF レイヤー | フロントエンド向けに最適化された 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 アプリケーションの構築に、きっと役立つはずです。
関連リンク
articletRPC アーキテクチャ設計:BFF とドメイン分割で肥大化を防ぐルータ戦略
articletRPC チートシート:Router/Procedure/ctx/useQuery/useMutation 早見表
articletRPC の始め方:Next.js App Router と Zod を使った最小構成テンプレート
articletRPC とは?型安全なフルスタック通信を実現する仕組みとメリット【2025 年版】
articleVite プラグインフック対応表:Rollup → Vite マッピング早見表
articleNestJS Monorepo 構築:Nx/Yarn Workspaces で API・Lib を一元管理
articleTypeScript Project References 入門:大規模 Monorepo で高速ビルドを実現する設定手順
articleMySQL Router セットアップ完全版:アプリからの透過フェイルオーバーを実現
articletRPC アーキテクチャ設計:BFF とドメイン分割で肥大化を防ぐルータ戦略
articleMotion(旧 Framer Motion)× TypeScript:Variant 型と Props 推論を強化する設定レシピ
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来