# 【UPDATE編-序章】IDでの単体取得フロー完全解説
## (1. バックエンド層)
### はじめに:なぜ「単体取得」が必要なのか?
更新フォームの画面を開いたとき、まず最初にやるべきことは、更新対象の現在の情報を画面に表示することです。例えば、「すごい椅子」という商品の価格を「10000円」から「12000円」に変更したい場合、フォームの入力欄には最初から「すごい椅子」「10000」と表示されている必要があります。
このドキュメントでは、そのために必要な「IDを指定して、特定の1つの商品情報を取得する」機能の全工程を解き明かします。
**バックエンドのフロー:** gRPCリクエスト -> Controller -> Service -> Repository -> Prisma -> DB
### Step 1: 契約の更新 - 「特定の商品をください」という新しいルールを決める
まず、BFFとの通信ルール(gRPCスキーマ)を更新し、「IDで商品を一件取得する」という新しい操作を定義します。
**レイヤー:** Backend (gRPC契約定義)
**ファイル:** `apps/backend/src/proto/template/product.proto`
```proto
// 📍 レイヤー: Backend (Protocol Buffers)
// 📂 ファイル: apps/backend/src/proto/template/product.proto
syntax = "proto3";
package product;
import "google/protobuf/empty.proto";
// ... 既存のProduct, ListProductResponseメッセージ ...
// --- ↓ここから追記 ---
// 「IDで商品をください」というリクエストの形
message GetProductRequest {
string id = 1;
}
// 既存のProductServiceに新しいRPCを追加
service ProductService {
rpc ListProducts(google.protobuf.Empty) returns (ListProductResponse);
// GetProductという名前でやり取りします、と宣言
// リクエストとしてGetProductRequestを受け取り、レスポンスとしてProductメッセージを返す
rpc GetProduct(GetProductRequest) returns (Product);
// ... UpdateProductなどもここに追加していく ...
}
ここでの解説:
-
rpc GetProduct(...)
: これが新しい機能の名前です。「IDで商品一件を取得する」という操作そのものを表します。 -
(GetProductRequest)
: この機能が受け取る入力です。どの商品が欲しいのかを伝えるためにidを含んでいます。 -
returns (Product)
: この機能は、見つかった商品情報(Productメッセージ)を一つだけ返します。
次に行うこと: このファイルを保存した後、pnpm proto-setup
を実行し、この新しいルールに対応したTypeScriptの型(GetProductRequest
インターフェースなど)を自動生成します。
Step 2: リポジトリの実装 - データベースから一件を探す
レイヤー: Backend
ファイル: apps/backend/src/features/product/product.repository.ts
Serviceから「このIDの商品を探して」と依頼を受けた際に、実際にPrismaを使ってデータベースから一件のレコードを探し出します。
// 📍 レイヤー: Backend (Repository)
// 📂 ファイル: apps/backend/src/features/product/product.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { Product as DomainProduct } from './domain/Product';
import { Product as PrismaProduct } from '@prisma/client';
@Injectable()
export class ProductRepository {
constructor(private readonly prisma: PrismaService) {}
// ... 既存のfindAll, createなど ...
// --- ↓ここから追記 ---
// 解説: Serviceから、探したい商品のIDを受け取ります。
async findById(id: string): Promise<DomainProduct | null> {
// 1. Prisma Clientの`findUnique`メソッドを使います。
// `where`句で、主キー(id)が一致するレコードを一件だけ探します。
const productEntity = await this.prisma.product.findUnique({
where: { id },
// ★ includeを使うことで、関連するカテゴリ情報も同時に取得できます
include: {
category: true,
}
});
// 2. もし商品が見つからなければ、nullを返します。
if (!productEntity) {
return null;
}
// 3. 見つかった場合は、DBの「生のデータ」を「ドメインモデル」に変換して返します。
return this.toDomain(productEntity);
}
// 既存の変換メソッド
private toDomain(entity: PrismaProduct & { category: Category }): DomainProduct {
// ... DBのデータからドメインオブジェクトを生成 ...
}
}
Step 3: サービスの実装 - 存在確認などのビジネスルールを実行
レイヤー: Backend
ファイル: apps/backend/src/features/product/product.service.ts
Controllerから依頼を受け、更新対象が存在するかどうかといった、ビジネス上のチェックを行います。
// 📍 レイヤー: Backend (Service)
// 📂 ファイル: apps/backend/src/features/product/product.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { ProductRepository } from './product.repository';
import { Product as DomainProduct } from './domain/Product';
@Injectable()
export class ProductService {
constructor(private readonly productRepository: ProductRepository) {}
// ... 既存のfindAll, createなど ...
// --- ↓ここから追記 ---
async findById(id: string): Promise<DomainProduct> {
// 1. Repositoryに商品の検索を依頼します。
const product = await this.productRepository.findById(id);
// 2. 【ビジネスロジック】もし商品が存在しなかった場合、
// 「見つかりません」というエラーを投げて処理を中断させます。
// これは、存在しない商品を更新しようとするのを防ぐための重要なチェックです。
if (!product) {
throw new NotFoundException(`ID: ${id} の商品は見つかりませんでした。`);
}
// 3. 無事に見つかった場合は、そのドメインモデルをControllerに返します。
return product;
}
}
Step 4: コントローラーの実装 - gRPCリクエストの受付
レイヤー: Backend
ファイル: apps/backend/src/features/product/product.controller.ts
BFFからのgRPCリクエストを最初に受け取る「窓口」です。
// 📍 レイヤー: Backend (Controller)
// 📂 ファイル: apps/backend/src/features/product/product.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { ProductService } from './product.service';
import { Product as ProtoProduct, GetProductRequest } from 'src/proto/interface/product.proto';
import { Product as DomainProduct } from './domain/Product';
@Controller()
export class ProductController {
constructor(private readonly productService: ProductService) {}
// ... 既存のListProductsなど ...
// --- ↓ここから追記 ---
@GrpcMethod('ProductService', 'GetProduct')
// 引数として、.protoから自動生成された`GetProductRequest`型を受け取ります。
async getProduct(request: GetProductRequest): Promise<ProtoProduct> {
// 1. 受け取ったリクエストからIDを取り出し、そのままServiceに渡します。
const domainProduct = await this.productService.findById(request.id);
// 2. Serviceから返ってきた「ドメインモデル」を、
// BFFに返すための「Protoの型」に変換します。
return this.domainToProto(domainProduct);
}
// ドメインモデルからProtoモデルへの変換関数
private domainToProto(product: DomainProduct): ProtoProduct {
// ... マッピング処理 ...
}
}
これで、バックエンド側は、BFFから**「IDを指定して、特定の商品情報を一件ください」**というgRPCリクエストを受け取り、その商品を返却する準備が整いました。
この後の流れとしては、
-
BFF編: このBackendの
GetProduct
機能を呼び出す、GraphQLのproduct(id: ID!)
クエリを実装します。 -
Frontend編: あなたの提案通り、このGraphQLクエリを呼び出す**
useProduct(id)
カスタムフック**を実装し、更新フォームの初期値として画面に表示します。
(2. BFF編)
はじめに:BFFの「翻訳者」としての役割
このドキュメントでは、フロントエンドから**「IDが'prod-123'の商品情報をください」**というGraphQLリクエストが来たときに、BFF内部で何が起きるのか、その全工程を解き明かします。
BFFは、フロントエンドが話す「GraphQL」という言語と、バックエンドが話す「gRPC」という言語の間に立つ、非常に優秀な**「通訳者」**です。両者の言語(プロトコル)と語彙(型)の違いを吸収し、スムーズな会話を実現します。
BFFのフロー: GraphQLリクエスト -> Resolver -> Service -> gRPCクライアント -> Backendへ
Step 1: GraphQLスキーマの定義 - フロントエンドとの「契約書」を作る
レイヤー: BFF (GraphQLスキーマ定義 - Code First)
ファイル: bff/src/features/product/product.resolver.ts
と bff/src/features/product/models/product.model.ts
「Code First」アプローチでは、TypeScriptのコード(リゾルバとモデル)がAPI仕様の「原本」となります。product.model.ts
は既存のものを使い、product.resolver.ts
に新しいクエリを追加します。
// 📍 レイヤー: BFF
// 📂 ファイル: bff/src/features/product/product.resolver.ts
import { Resolver, Query, Args, ID } from '@nestjs/graphql';
import { BffProductService } from './product.service';
import { Product } from './models/product.model';
import { NotFoundException } from '@nestjs/common';
@Resolver(() => Product)
export class ProductResolver {
constructor(private readonly productService: BffProductService) {}
// ... 既存の listProducts クエリ ...
// --- ↓ここから追記 ---
// 解説: GraphQLスキーマの`Query`型に、`product`という名前の新しいクエリを追加します。
@Query(() => Product, { name: 'product', nullable: true })
async getProduct(
// @Argsデコレータで、GraphQLクエリの引数を受け取ります。
// 'id'という名前で、GraphQLのID型として受け取ることを宣言します。
@Args('id', { type: () => ID }) id: string,
): Promise<Product | null> {
// Resolverの仕事は、リクエストを適切なServiceに渡すことだけです。
const product = await this.productService.findById(id);
// Serviceからnullが返ってきた場合(見つからなかった場合)も考慮します。
if (!product) {
// GraphQLではエラーを投げる代わりに、nullを返すのが一般的です。
return null;
}
return product;
}
}
ここでの解説:
-
@Query(() => Product, { ... })
: このメソッドが、Product型を一つ返すQueryであることを定義します。name: 'product'
でクエリ名を指定しています。 -
@Args('id', { type: () => ID }) id: string
: フロントエンドからproduct(id: "...")
という形で渡される引数を受け取るための定義です。
次に行うこと: このファイルを保存すると、pnpm start:dev
が動いていれば、BFFが提供するschema.graphql
が自動的に更新され、product(id: ID!): Product
というクエリが追加されます。
Step 2: サービスの実装 - 「プロトコル翻訳」の実行
レイヤー: BFF
ファイル: bff/src/features/product/product.service.ts
Resolverから依頼を受け、実際にBackendへgRPCリクエストを送信する「翻訳者」です。
// 📍 レイヤー: BFF
// 📂 ファイル: bff/src/features/product/product.service.ts
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
import {
PRODUCT_PACKAGE_NAME,
PRODUCT_SERVICE_NAME,
ProductServiceClient,
Product as ProtoProduct, // ★ gRPCの型
} from '../../generated/proto/app/service/exampleProduct.proto';
import { Product as GraphQLProduct } from './models/product.model'; // ★ GraphQLの型
@Injectable()
export class BffProductService implements OnModuleInit {
private productService!: ProductServiceClient;
constructor(
@Inject(PRODUCT_PACKAGE_NAME) private readonly client: ClientGrpc,
) {}
onModuleInit() {
this.productService =
this.client.getService<ProductServiceClient>(PRODUCT_SERVICE_NAME);
}
// ... 既存の findAll メソッド ...
// --- ↓ここから追記 ---
// 解説: Resolverから、探したい商品のIDを受け取ります。
async findById(id: string): Promise<GraphQLProduct | null> {
try {
// 1. 準備しておいたgRPCクライアントを使い、Backendの`GetProduct`メソッドを呼び出します。
// 引数として、.protoで定義した`GetProductRequest`の形に合わせて`{ id }`を渡します。
const response: ProtoProduct = await firstValueFrom(
this.productService.getProduct({ id }),
);
// 2. ★★★ここがBFFの翻訳処理の核心★★★
// Backendから返ってきたgRPCの型(`ProtoProduct`)を、フロントエンドに返すGraphQLの型(`GraphQLProduct`)に変換します。
// 今回はプロパティ名がほぼ同じなので、そのまま返すことができますが、
// もし形が違えば、ここでマッピング処理を行います。
// 例えば、gRPCのpriceがstringで、GraphQLのpriceがnumberの場合、ここで変換します。
return {
...response,
price: Number(response.price), // 型変換の例
};
} catch (error) {
// BackendのServiceがNotFoundExceptionを投げると、gRPCエラーとしてここに到達します。
// エラーログは残しつつ、フロントエンドには「見つからなかった」ことを示すnullを返します。
console.error(`商品(ID: ${id})の取得に失敗しました:`, error);
return null;
}
}
}
ここでの解説:
- findByIdメソッドの追加: Resolverからの要求に応えるため、IDを引数に取る新しいメソッドを追加しました。
-
gRPC呼び出し:
this.productService.getProduct({ id })
で、Backendに定義したGetProduct
RPCを正確に呼び出しています。 -
エラーハンドリング:
try...catch
ブロックで、Backendからエラーが返ってきた場合(例:商品が見つからなかった)を適切に処理しています。これにより、BFFサーバーがクラッシュするのを防ぎ、フロントエンドには安全にnull
を返すことができます。 -
型の翻訳:
return { ...response, price: Number(response.price) }
の部分で、Backend語のデータ(ProtoProduct
)を、フロントエンド語のデータ(GraphQLProduct
)に変換しています。
Step 3: モジュールとDIの確認
これらの新しい機能や依存関係が正しく動作するように、モジュールファイルを確認します。ほとんどの場合、このステップでは既存のファイルに修正は不要ですが、全体の連携を理解するために重要です。
-
bff/src/features/product/product.module.ts
:
このモジュールがProductResolver
とBffProductService
をproviders
として持ち、ClientsModule
をimports
していることを確認します。 -
bff/src/grpc-client.options.ts
:
PRODUCT_PACKAGE_NAME
に対応するgRPCクライアントの定義が存在することを確認します。 -
bff/src/app.module.ts
:
ルートモジュールがProductModule
をimports
していることを確認します。
これで、BFF側の準備も万端です。BFFは、フロントエンドからのproduct(id: "...")
というGraphQLクエリを受け取り、Backendから取得した特定の商品情報を返せるようになりました。
(3. フロントエンド編)
はじめに:フロントエンドの役割とゴール
このドキュメントでは、ユーザーが社員一覧画面で**「編集」ボタン**を押したときに、BFFから特定の社員の情報を取得し、編集用のDrawerフォームにその情報を初期値として表示するまでの全工程を解き明かします。
フロントエンドのフロー:
GraphQLクエリ定義 -> pnpm codegen
-> 型/フック自動生成 -> カスタムフックでロジックをカプセル化 -> 親コンポーネントで状態を管理 -> 子コンポーネント(Drawer)でデータを表示
Step 1: 「欲しいものリスト」の作成 - GraphQLオペレーションを定義する
まず、BFFから**「IDを指定して、特定の社員情報をください」**という「注文書」をGraphQLで定義します。
レイヤー: Frontend (GraphQL定義)
ファイル: apps/frontend/src/graphql/operations/user.graphql
(追記)
# 📍 レイヤー: Frontend
# 📂 ファイル: apps/frontend/src/graphql/operations/user.graphql
# ... 既存のCreateUserやGetFormOptionsなど ...
# --- ↓ここから追記 ---
# 「GetUserById」という名前のクエリ(注文書)を定義
# このクエリは、必須のIDを引数として受け取ります。
query GetUserById($id: ID!) {
# BFFの`user(id: ...)`というAPIを呼び出してください
user(id: $id) {
# 返してほしいユーザーのフィールドはこれだけです
id
employeeNumber
name
email
# 関連情報もネストして取得
belonging {
id
}
team {
id
}
workplace {
id
}
}
}
ここでの解説:
-
$id: ID!
: このクエリがid
という名前の変数を必須で受け取ることを示します。!
は必須を意味します。 -
user(id: $id)
: 受け取った$id
変数を、BFFのuser
クエリの引数として渡しています。
次に行うこと: このファイルを保存した後、pnpm --filter frontend codegen
を実行します。これにより、次のステップで使う「魔法の道具」がgenerated.ts
に自動生成されます。
Step 2: データ取得ロジックのカプセル化 - カスタムフックの作成
次に、UIコンポーネントが直接データ取得のロジックを持たないように、責務をカスタムフックに分離します。これが**「カスタムフックにするのがいいかな?」**というあなたの発想を形にする部分です。
レイヤー: Frontend (カスタムフック)
ファイル: apps/frontend/src/hooks/useUser.ts
(新規作成)
// 📍 レイヤー: Frontend
// 📂 ファイル: apps/frontend/src/hooks/useUser.ts
import { useQuery } from 'urql';
// ★Step 1の`pnpm codegen`で自動生成された「魔法の道具」をインポート
import {
GetUserByIdQuery,
GetUserByIdQueryVariables,
GetUserByIdDocument,
} from '@/graphql/generated';
// 解説: このフックは、ユーザーIDを引数として受け取ります。
export const useUser = (id: string | null) => {
// URQLのuseQueryフックに、自動生成された「型」と「注文書」を渡します。
const [result] = useQuery<GetUserByIdQuery, GetUserByIdQueryVariables>({
query: GetUserByIdDocument,
// ★★★ここが最重要ポイント★★★
// `variables`には、クエリが必要とする引数を渡します。
variables: { id: id || '' },
// `pause`は、クエリの実行を一時停止するためのオプションです。
// IDが渡されていない(`null`の)場合は、無駄な通信をしないようにクエリを停止させます。
pause: !id,
});
// UIコンポーネントが使いやすいように、結果を整形して返します。
return {
user: result.data?.user,
loading: result.fetching,
error: result.error,
};
};
ここでの解説:
-
useUser(id: string | null)
: IDが渡されていない状態(編集Drawerが開かれていない状態)も考慮し、引数の型をstring | null
としています。 -
pause: !id
: これが非常に重要です。id
がnull
や空文字列の場合、!id
はtrue
となり、URQLはBFFへのリクエストを送信しません。IDが渡された瞬間に初めて通信が実行されます。これにより、不要なAPIコールを防ぎます。
Step 3: 親コンポーネントの実装 - 「誰を編集するか」を管理する
次に、社員一覧を表示し、「編集」ボタンが押されたことを検知して、どの社員を編集対象にするかを管理する親コンポーネントを実装します。
レイヤー: Frontend (UIコンポーネント)
ファイル: apps/frontend/src/app/users/page.tsx
(例)
// 📍 レイヤー: Frontend
// 📂 ファイル: apps/frontend/src/app/users/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { EditUserDrawer } from '@/components/EditUserDrawer'; // これから作る編集用Drawer
// 仮のユーザー一覧データ
const userList = [
{ id: 'user-1', name: '山田 太郎' },
{ id: 'user-2', name: '鈴木 一郎' },
];
export default function UserManagementPage() {
// 1. 編集対象のユーザーIDを保持するためのstate
// 最初は誰も選択されていないので、nullで初期化します。
const [editingUserId, setEditingUserId] = useState<string | null>(null);
// 2. 「編集」ボタンが押されたときに、対象のIDをセットする関数
const handleEditClick = (userId: string) => {
setEditingUserId(userId);
};
// 3. Drawerが閉じられたときに、編集対象IDをリセットする関数
const handleCloseDrawer = () => {
setEditingUserId(null);
};
return (
<div>
<h1>社員一覧</h1>
<table>
{/* ...テーブルヘッダー... */}
<tbody>
{userList.map(user => (
<tr key={user.id}>
<td>{user.name}</td>
<td>
<Button onClick={() => handleEditClick(user.id)}>編集</Button>
</td>
</tr>
))}
</tbody>
</table>
{/* ★★★ここが連携のポイント★★★ */}
{/* 編集用Drawerコンポーネントを呼び出す */}
<EditUserDrawer
// 編集対象のIDをpropsとして渡す
userId={editingUserId}
// Drawerが開いているかどうかを、IDの有無で判断
isOpen={!!editingUserId}
// Drawerが閉じるイベントを受け取る
onClose={handleCloseDrawer}
/>
</div>
);
}
ここでの解説:
- 親コンポーネントの役割は、「今、どのユーザーが編集対象なのか(
editingUserId
)」という状態を管理することです。 - 「編集」ボタンが押されると
setEditingUserId
が呼ばれ、editingUserId
にIDがセットされます。 -
isOpen={!!editingUserId}
という書き方は、「editingUserId
に値があればtrue
、null
ならfalse
」となり、IDがセットされた瞬間にDrawerが開く、という挙動を実現します。
Step 4: 子コンポーネント(Drawer)の実装 - データ取得とフォームへの表示
いよいよ最終段階です。親から渡されたuserId
を使ってデータを取得し、フォームに初期値として表示するDrawerコンポーネントを実装します。
レイヤー: Frontend (UIコンポーネント)
ファイル: apps/frontend/src/components/EditUserDrawer.tsx
(新規作成)
// 📍 レイヤー: Frontend
// 📂 ファイル: apps/frontend/src/components/EditUserDrawer.tsx
'use client';
import { useEffect, useState } from 'react';
import { Drawer, DrawerBody, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton, Button, Stack, Box, FormLabel, Input, Spinner } from '@chakra-ui/react';
import { useUser } from '@/hooks/useUser'; // Step 2で作成したカスタムフック
type EditUserDrawerProps = {
isOpen: boolean;
onClose: () => void;
userId: string | null;
};
export const EditUserDrawer = ({ isOpen, onClose, userId }: EditUserDrawerProps) => {
// 1. 親から渡された`userId`を使って、データ取得フックを呼び出す
const { user, loading, error } = useUser(userId);
// 2. フォームの入力値を管理するためのstate
const [formData, setFormData] = useState({ name: '', email: '' });
// 3. ★★★ここが、データ表示の核心★★★
// `useEffect`を使って、データ取得の完了を監視します。
useEffect(() => {
// データが取得され、`user`オブジェクトが存在する場合にのみ実行
if (user) {
// 取得したデータで、フォームの状態を更新(初期値をセット)する
setFormData({
name: user.name,
email: user.email,
// ...他のフィールドも同様にセット
});
}
}, [user]); // `user`が変化した時だけ、この処理が走ります
return (
<Drawer isOpen={isOpen} placement="right" onClose={onClose}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader>社員情報の編集</DrawerHeader>
<DrawerBody>
{/* ローディング中やエラーの場合の表示 */}
{loading && <Spinner />}
{error && <p>データの取得に失敗しました。</p>}
{/* データ取得後にフォームを表示 */}
{!loading && !error && user && (
<Stack spacing="24px">
<Box>
<FormLabel>氏名</FormLabel>
<Input value={formData.name} onChange={/* ... */} />
</Box>
<Box>
<FormLabel>メールアドレス</FormLabel>
<Input value={formData.email} onChange={/* ... */} />
</Box>
{/* 他の入力フィールドも同様 */}
</Stack>
)}
</DrawerBody>
<DrawerFooter>
<Button variant="outline" mr={3} onClick={onClose}>
キャンセル
</Button>
<Button colorScheme="blue">更新</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
};
ここでの解説:
-
useUser(userId)
: 親から渡されたuserId
がnull
から具体的なIDに変わった瞬間に、このフックはBFFへのデータ取得を開始します。 -
useEffect(() => { ... }, [user])
: このフックが「見張り番」です。最初はuser
がundefined
ですが、データ取得が完了してuser
オブジェクトに中身が入った瞬間に、useEffect
の中の処理が実行されます。 -
setFormData({ ... })
:useEffect
の中でsetFormData
を呼び出すことで、取得したuser.name
やuser.email
がフォームの状態にセットされ、結果として入力欄に初期値が表示される、という流れです。
これで、編集ボタンを押してから、フォームに既存の情報が表示されるまでの、完全なデータ取得フローが完成しました。