1. DB層で発生するエラーの種類と Prisma 固有エラーコード

酒本先輩!
第14回で Prisma の基本 CRUD やトランザクションの使い方は完璧になりました。
でも、実際に運用していると『存在しない ID を指定して更新・削除しようとした』とか『ユニーク制約(重複不可)に違反した』みたいなエラーが DB 側で起きますよね。
これってどうやってキャッチしてクライアントに返せばいいんですか?

いいところに気づいたわね!
Prisma は DB 操作時に予期せぬエラーが起きると PrismaClientKnownRequestError という例外を投げるの。
Prisma 7 では、このクラスは生成された Prisma Client の Prisma 名前空間(Prisma.PrismaClientKnownRequestError)から使うわ。
エラーは P2000 番台の独自エラーコードで分類されているの。
実務で特に頻出する代表的なコードを押さえておきましょう。
- P2002(Unique constraint failed)
- エラーの概要:一意制約違反(既に登録されているメールアドレスなど)
- 主な発生原因:既存のユニークキーと重複する値を登録・更新しようとした
- 適切な HTTP ステータスコード:409 Conflict
- P2025(Record to update/delete not found)
- エラーの概要:存在しないレコードの更新・削除操作
- 主な発生原因:指定した ID のレコードが DB 上に存在しない
- 適切な HTTP ステータスコード:404 Not Found
- P2003(Foreign key constraint failed)
- エラーの概要:存在しない外部キー(親 ID)の指定
- 主な発生原因:親テーブルに存在しない ID を参照して登録しようとした
- 適切な HTTP ステータスコード:400 Bad Request
- P2000(Value too long for column)
- エラーの概要:カラムの最大文字数制限オーバー
- 主な発生原因:DB で定義された型の上限を超える長さの文字列を渡した
- 適切な HTTP ステータスコード:400 Bad Request

P2002 は、今の CartItem だとユニーク制約を持つカラムがないので発生しなさそうですね。

鋭いわね。
P2002 は次回(第16回)で User の email に @unique を付けたときに本物が出てくるわ。
今回は変換の仕組みの中に組み込んでおいて、第16回でそのまま使い回しましょう。
2. アプリケーション層への伝播:Prisma エラーをドメイン例外へ変換する

第12回で『レイヤードアーキテクチャ』を学んだわよね。
覚えているかしら?

はい!
Service 層や Controller 層は、特定のデータベース技術(Prisma)に依存させてはいけないという原則ですね!

その通り!
もし Repository 層から PrismaClientKnownRequestError をそのまま上位(Service や Controller)に投げてしまうと、Service 層が『Prisma のエラー』を知っていなければならなくなり、密結合になってしまうの。
だから Repository 層で Prisma 固有のエラーをキャッチし、アプリケーション定義の抽象的なカスタム例外(NotFoundError や ConflictError など)に変換(ラップ)して投げるのが実務の鉄則よ!
【エラー変換のデータフロー】
1. PostgreSQL (エラー発生)
│
▼
2. Prisma Client (PrismaClientKnownRequestError: P2025)
│
▼ [Repository 層で変換!]
3. カスタム例外 (NotFoundError / ConflictError)
│
▼
4. Service 層 / Controller 層 (Prismaへの依存ゼロ!)
│
▼
5. Global Error Handler
(第9回で作り、第12回で error.middleware.ts に整理した共通処理)
│
▼
6. クライアント (404 Not Found / 409 Conflict の JSON レスポンス)

5番の Global Error Handler は、今のままで NotFoundError を 404 に変換してくれるんですか?

いいえ。
第12回で作った errorHandler は『不正な JSON』と『それ以外は全部 500』しか知らないの。
このままだと NotFoundError を投げても 500 が返ってしまうわ。
だからこの回では、カスタム例外を作るだけでなく、errorHandler も AppError に対応させるのがセットよ。
3. 実践:カスタム例外クラスと Repository 層でのエラーハンドリング

順番に作っていきましょう。
まずはカスタム例外クラスよ。
1. カスタム例外クラスの定義(src/errors/app.error.ts)
HTTP ステータスコードに加えて、第12回のレスポンス形式(error に識別コード、message に説明)に合わせるための code も持たせます。
// src/errors/app.error.ts
// ベースとなるアプリケーション例外
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
// 不正なリクエスト (400)
export class BadRequestError extends AppError {
constructor(message = 'リクエストの内容が正しくありません') {
super(message, 400, 'BAD_REQUEST');
}
}
// リソース未存在エラー (404)
export class NotFoundError extends AppError {
constructor(message = '指定されたリソースが見つかりません') {
super(message, 404, 'NOT_FOUND');
}
}
// 一意制約・衝突エラー (409)
export class ConflictError extends AppError {
constructor(message = 'リソースが既に存在するか衝突しています') {
super(message, 409, 'CONFLICT');
}
}
2. Prisma エラーの変換ユーティリティ(src/repositories/prisma/prisma-error.util.ts)
エラーコードごとの if を Repository のメソッドごとに書くと重複するので、変換処理を1か所にまとめます。変換対象外のエラー(想定外の障害)はそのまま返し、呼び出し側で再スローします。
// src/repositories/prisma/prisma-error.util.ts
import { Prisma } from '../../generated/prisma/client/client.js';
import { AppError, BadRequestError, ConflictError, NotFoundError } from '../../errors/app.error.js';
interface ErrorMessages {
notFound?: string; // P2025 用メッセージ
conflict?: string; // P2002 用メッセージ
}
/**
* Prisma 固有のエラーをアプリケーション定義の例外へ変換する。
* 変換対象外のエラーはそのまま返す(呼び出し側で throw する)。
*/
export const toAppError = (error: unknown, messages: ErrorMessages = {}): unknown => {
if (error instanceof AppError) {
return error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
switch (error.code) {
case 'P2025': // 更新・削除対象のレコードが存在しない
return new NotFoundError(messages.notFound);
case 'P2002': // 一意制約違反
return new ConflictError(messages.conflict);
case 'P2003': // 外部キー制約違反
return new BadRequestError('関連するデータが存在しません');
case 'P2000': // カラムの最大長オーバー
return new BadRequestError('入力値が長すぎます');
}
}
return error;
};
3. リポジトリのインターフェースと実装
「見つからなかったら undefined や false を返す」設計だと、呼び出し側が毎回 if で分岐する必要があります。
今回から 「対象が存在しない場合は NotFoundError を投げる」 契約に統一するので、インターフェースの戻り値も変わります。
// src/repositories/interfaces/cart.repository.interface.ts
import { CartItem } from '../../types/cart.type.js';
export interface ICartRepository {
findAll(): Promise<CartItem[]>;
findById(id: number): Promise<CartItem | undefined>;
create(data: Omit<CartItem, 'id'>): Promise<CartItem>;
/** 対象が存在しない場合は NotFoundError を投げる */
updateQuantity(id: number, quantity: number): Promise<CartItem>;
/** 対象が存在しない場合は NotFoundError を投げる */
delete(id: number): Promise<void>;
clearCart(): Promise<void>;
}

あれ、先輩。
実装のコンストラクタで new PrismaClient() を書いて済ませるコードをよく見かけますけど、第14回で『True DI』にしたばかりですよね?

そのとおり!
ここで new PrismaClient() に戻してしまったら、第14回の番外編で話した『テストでモックに差し替えられない』問題が復活してしまうわ。
PrismaClient は引き続きコンストラクタで外から受け取るわよ。
// src/repositories/prisma/prisma-cart.repository.ts
import { PrismaClient, Prisma } from '../../generated/prisma/client/client.js';
import { ICartRepository } from '../interfaces/cart.repository.interface.js';
import { CartItem } from '../../types/cart.type.js';
import { toAppError } from './prisma-error.util.js';
export class PrismaCartRepository implements ICartRepository {
// 第14回と同じ True DI:PrismaClient は外部から注入する
constructor(private readonly prisma: PrismaClient) {}
private mapToDomain(raw: { id: number; productName: string; price: number; quantity: number }): CartItem {
return {
id: raw.id,
productName: raw.productName,
price: raw.price,
quantity: raw.quantity,
};
}
private async getOrCreateDefaultCartId(): Promise<number> {
let cart = await this.prisma.cart.findFirst();
if (!cart) {
cart = await this.prisma.cart.create({ data: {} });
}
return cart.id;
}
async findAll(): Promise<CartItem[]> {
const items = await this.prisma.cartItem.findMany({
orderBy: { id: 'asc' },
});
return items.map((item) => this.mapToDomain(item));
}
async findById(id: number): Promise<CartItem | undefined> {
const item = await this.prisma.cartItem.findUnique({
where: { id },
});
return item ? this.mapToDomain(item) : undefined;
}
async create(data: Omit<CartItem, 'id'>): Promise<CartItem> {
const cartId = await this.getOrCreateDefaultCartId();
try {
const created = await this.prisma.cartItem.create({
data: {
productName: data.productName,
price: data.price,
quantity: data.quantity,
cartId,
},
});
return this.mapToDomain(created);
} catch (error) {
throw toAppError(error); // P2000(文字列が長すぎる)などを 400 系へ変換
}
}
async updateQuantity(id: number, quantity: number): Promise<CartItem> {
try {
const updated = await this.prisma.cartItem.update({
where: { id },
data: { quantity },
});
return this.mapToDomain(updated);
} catch (error) {
// P2025(対象なし)→ NotFoundError。想定外のエラーはそのまま再スロー
throw toAppError(error, { notFound: `ID: ${id} のカートアイテムが見つかりません` });
}
}
async delete(id: number): Promise<void> {
try {
await this.prisma.cartItem.delete({
where: { id },
});
} catch (error) {
throw toAppError(error, { notFound: `削除対象の ID: ${id} が存在しません` });
}
}
async clearCart(): Promise<void> {
const cartId = await this.getOrCreateDefaultCartId();
await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.cartItem.deleteMany({
where: { cartId },
});
});
}
}

ポイントは update と delete の catch ブロックよ。
P2025 なら NotFoundError に、想定外のエラーは toAppError が元のエラーをそのまま返すから、throw で上位(Global Error Handler)に任せられるの。
補足
第12回で作った src/repositories/memory/(メモリ実装)と src/tests/cart.service.test.ts は、インターフェースの変更(clearCart の追加、updateQuantity / delete の戻り値変更)に対応していないため、そのままだと tsc でコンパイルエラーになります。
メモリ実装は第13回で Prisma に置き換え済みなので削除してください。
テストコードは第19回で tests/ ディレクトリに作り直します。
4. Global Error Handler の対応(src/middlewares/error.middleware.ts)
AppError を最初に判定し、statusCode と code をそのままレスポンスに反映します。
// src/middlewares/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/app.error.js';
export const errorHandler = (
err: unknown,
req: Request,
res: Response,
next: NextFunction
): void => {
// ① アプリケーション定義の例外(NotFoundError / ConflictError など)
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: err.code,
message: err.message,
});
return;
}
// ② 不正な JSON ボディ(第12回と同じ)
if (err instanceof SyntaxError && 'status' in err && err.status === 400 && 'body' in err) {
res.status(400).json({
error: 'INVALID_JSON_SYNTAX',
message: 'リクエストの JSON 構文が正しくありません。フォーマットを確認してください。',
});
return;
}
// ③ 想定外のエラー(詳細はログにだけ残し、クライアントには見せない)
console.error('[Unhandled Error]:', err);
res.status(500).json({
error: 'INTERNAL_SERVER_ERROR',
message: 'サーバー内部で予期しないエラーが発生しました。',
});
};
5. Service・Controller・Route の更新

Repository が例外を投げるようになったので、Service と Controller の 404 判定も変わりますよね?

ええ。
例外は Controller の try/catch で拾わなくても、Express 5 は async ハンドラーが投げた例外を自動で next(err) に渡してくれるから、そのまま Global Error Handler まで届くの。
Controller からは 404 判定の if が消えてスッキリするわ。
もうひとつ。
第14回の Controller は parseInt(req.params.id) していたけれど、/items/abc のような ID が来ると NaN が Prisma に渡って 500 になってしまうの。
第11回で作った cartItemIdParamSchema をルートに適用して、不正な ID は 400 で弾きましょう。
// src/services/cart.service.ts
import { ICartRepository } from '../repositories/interfaces/cart.repository.interface.js';
import { CartItem } from '../types/cart.type.js';
export class CartService {
constructor(private readonly cartRepository: ICartRepository) {}
getCartSummary = async () => {
const items = await this.cartRepository.findAll();
const totalCount = items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return { items, totalCount, totalPrice };
};
addItem = async (data: Omit<CartItem, 'id'>) => {
return await this.cartRepository.create(data);
};
updateItemQuantity = async (id: number, quantity: number) => {
return await this.cartRepository.updateQuantity(id, quantity);
};
removeItem = async (id: number): Promise<void> => {
await this.cartRepository.delete(id);
};
clearAll = async (): Promise<void> => {
await this.cartRepository.clearCart();
};
}
// src/controllers/cart.controller.ts
import { Request, Response } from 'express';
import { CartService } from '../services/cart.service.js';
// Express 5 では async ハンドラーの例外・reject が自動で next(err) に渡されるため、
// 各メソッドに try/catch を書かなくても Global Error Handler に届く。
export class CartController {
constructor(private readonly cartService: CartService) {}
getSummary = async (req: Request, res: Response): Promise<void> => {
const summary = await this.cartService.getCartSummary();
res.json(summary);
};
createItem = async (req: Request, res: Response): Promise<void> => {
const { productName, price, quantity } = req.body;
const item = await this.cartService.addItem({ productName, price, quantity });
res.status(201).json(item);
};
updateQuantity = async (req: Request, res: Response): Promise<void> => {
// :id は validateRequest(cartItemIdParamSchema) で正の整数であることを検証済み
const id = Number(req.params.id);
const { quantity } = req.body;
const updated = await this.cartService.updateItemQuantity(id, quantity);
res.json(updated);
};
deleteItem = async (req: Request, res: Response): Promise<void> => {
const id = Number(req.params.id);
await this.cartService.removeItem(id);
res.status(204).send();
};
clearCart = async (req: Request, res: Response): Promise<void> => {
await this.cartService.clearAll();
res.status(204).send();
};
}
// src/routes/cart.route.ts
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { PrismaCartRepository } from '../repositories/prisma/prisma-cart.repository.js';
import { CartService } from '../services/cart.service.js';
import { CartController } from '../controllers/cart.controller.js';
import { validateRequest } from '../middlewares/validate.middleware.js';
import {
createCartItemSchema,
updateCartItemSchema,
cartItemIdParamSchema,
} from '../schemas/cart.schema.js';
// 依存関係の組み立て(DI)
const cartRepository = new PrismaCartRepository(prisma);
const cartService = new CartService(cartRepository);
const cartController = new CartController(cartService);
const router = Router();
router.get('/items', cartController.getSummary);
router.post('/items', validateRequest({ body: createCartItemSchema }), cartController.createItem);
router.patch(
'/items/:id',
validateRequest({ params: cartItemIdParamSchema, body: updateCartItemSchema }),
cartController.updateQuantity
);
router.delete('/items/:id', validateRequest({ params: cartItemIdParamSchema }), cartController.deleteItem);
router.delete('/items', cartController.clearCart);
export default router;
6. エントリーポイントの確認(src/app.ts)

あれ?
第14回の app.ts には errorHandler の登録も app.listen もありませんでした…。

よく見ているわね。
第14回の抜粋では、第12回で書いた『404 ハンドラー』『errorHandler の登録』『listen』が省略されてしまっていたの。
app.use(errorHandler) が無いと、せっかく NotFoundError を投げても Express 標準の HTML エラーページが返ってしまうわ。
第12回の形に戻して、全文を載せておくわね。
errorHandler は必ずルーティングより後ろに登録するのがポイントよ。
// src/app.ts
import express, { Application, Request, Response } from 'express';
import cartRouter from './routes/cart.route.js';
import { errorHandler } from './middlewares/error.middleware.js';
const app: Application = express();
const PORT = 3000;
app.use(express.json());
app.use('/api/cart', cartRouter);
app.get('/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});
// 存在しないエンドポイント(404)
app.use((req: Request, res: Response) => {
res.status(404).json({
error: 'NOT_FOUND',
message: '要求されたエンドポイントが存在しません。',
});
});
// Global Error Handler(必ず最後に登録する)
app.use(errorHandler);
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => {
console.log(`[Server] App running on http://localhost:${PORT}`);
});
}
export default app;
4. Prisma Seed(初期データ投入)の役割と仕組み

エラー処理はバッチリ理解できました!
ところで先輩、開発中に動作確認をするとき、毎回 curl で1件ずつデータを登録するのって結構面倒ですよね…?
Docker や DB をリセットしたときに自動でテストデータを流し込む方法ってないんですか?

それがまさに 『シーディング(Seeding)』 よ!
Prisma には標準で初期データを全自動で投入する prisma db seed の仕組みが備わっているわ。
これを使うことで、ローカル開発環境の構築やテストデータのセットアップが一瞬で完了するの。
【Prisma Seed の動作フロー】
1. npx prisma db seed 実行
│
▼
2. prisma7.config.ts の migrations.seed に書かれたコマンドを参照
│
▼
3. prisma/seed.ts が実行され、マスターデータや初期レコードを一括作成!

ネットの記事だと、package.json に “prisma”: { “seed”: “…” } と書く方法が載っていますが…?

それは Prisma 6 までの書き方よ。
Prisma 7 では package.json の prisma.seed は読み込まれなくなって、第13回で作った設定ファイルの migrations.seed に書くの。
書き忘れると『No seed command configured』と警告されて何も起きないから気をつけてね。
5. 実践:Prisma シードスクリプトの作成と実行手順
1. シードコマンドの登録(prisma7.config.ts)
第13回で npx prisma init が生成した設定ファイルに、migrations ブロックを追加します。
// prisma7.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts", // `npx prisma db seed` で実行されるコマンド
},
datasource: {
url: env("DATABASE_URL"),
},
});
2. シードスクリプトの作成(prisma/seed.ts)
シードも src/config/database.ts で作った共有の prisma を使います(@prisma/client から new PrismaClient() する書き方は、Prisma 7 の新しい Client では使えません)。
// prisma/seed.ts
import { prisma } from '../src/config/database.js';
async function main() {
console.log('🌱 初期データの投入を開始します...');
// 1. 既存データをクリア(リセット)。CartItem は Cart の onDelete: Cascade でも消えるが明示しておく
await prisma.cartItem.deleteMany({});
await prisma.cart.deleteMany({});
// 2. デフォルトのカートと、カート内の商品をネストして一括作成
const cart = await prisma.cart.create({
data: {
items: {
create: [
{
productName: 'HHKB Professional HYBRID Type-S',
price: 36850,
quantity: 1,
},
{
productName: 'Dell UltraSharp 27インチ 4Kモニター',
price: 64800,
quantity: 2,
},
{
productName: 'Anker USB-C Hub 7-in-1',
price: 6990,
quantity: 1,
},
],
},
},
});
console.log(`✅ シード完了: カートID ${cart.id} に 3 件の初期商品を投入しました。`);
}
main()
.catch((e) => {
console.error('❌ シード処理中にエラーが発生しました:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
database.ts は、prisma db seed のように単体で実行されるスクリプトからも .env を読めるよう、先頭に import ‘dotenv/config’; を1行追加します。
// src/config/database.ts
import 'dotenv/config'; // seed など単体実行でも .env を読み込めるようにする
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
import { PrismaClient } from '../generated/prisma/client/client.js';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL が .env に設定されていません。');
}
const pool = new pg.Pool({ connectionString });
const adapter = new PrismaPg(pool);
// アプリ全体で共有・注入するための PrismaClient インスタンス
export const prisma = new PrismaClient({ adapter });
3. シードコマンドの実行
ターミナルで以下のコマンドを実行します。
npx prisma db seed
実行出力ログ:
Loaded Prisma config from prisma7.config.ts.
Running seed command `tsx prisma/seed.ts` ...
🌱 初期データの投入を開始します...
✅ シード完了: カートID 1 に 3 件の初期商品を投入しました。
🌱 The seed command has been executed.
補足
連番の ID(autoincrement)は、データを削除しても 1 に戻りません。
ID が 1 から始まる状態をやり直したいときは、npx prisma migrate reset で DB をリセットしてから、改めて npx prisma db seed を実行してください。
6. 動作確認:存在しない ID の指定とエラーハンドリングの検証
サーバーを起動します(.env に DATABASE_URL が設定済みであること)。
npm run dev
1. 存在しない ID(999)のアイテム更新を試行(PATCH リクエスト)
curl -i -X PATCH http://localhost:3000/api/cart/items/999 \
-H "Content-Type: application/json" \
-d '{"quantity": 5}'
実行結果(レスポンス):
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{
"error": "NOT_FOUND",
"message": "ID: 999 のカートアイテムが見つかりません"
}
2. 不正な ID(abc)を指定(PATCH リクエスト)
curl -i -X PATCH http://localhost:3000/api/cart/items/abc \
-H "Content-Type: application/json" \
-d '{"quantity": 5}'
実行結果:
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{
"error": "VALIDATION_ERROR",
"message": "リクエストデータに不備があります。",
"details": [
{
"field": "id",
"message": "Invalid input: expected number, received NaN",
"rule": "invalid_type"
}
]
}
3. シードで投入された初期データの確認(GET リクエスト)
curl -i http://localhost:3000/api/cart/items
実行結果:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"items": [
{
"id": 1,
"productName": "HHKB Professional HYBRID Type-S",
"price": 36850,
"quantity": 1
},
{
"id": 2,
"productName": "Dell UltraSharp 27インチ 4Kモニター",
"price": 64800,
"quantity": 2
},
{
"id": 3,
"productName": "Anker USB-C Hub 7-in-1",
"price": 6990,
"quantity": 1
}
],
"totalCount": 4,
"totalPrice": 173440
}

totalCount は商品の『点数』の合計(1 + 2 + 1 = 4)で、種類数の 3 ではないわよ。
第14回の CartService が quantity を合計する実装だったからね。

完璧です!
存在しない ID の操作時にはキレイに 404 Not Found が返ってきましたし、不正な ID も 400 で弾かれました。
npx prisma db seed 1発で開発用の初期データが一括セットアップできたのも嬉しいです!
本日のまとめ
- Prisma 固有エラーの把握:
P2025(Not Found)や P2002(Unique Constraint)など、Prisma 固有のエラーコードの意味と適切な HTTP ステータスコードの対応を理解する。 - レイヤードアーキテクチャでのエラー変換:
Repository 層で Prisma 固有のエラーをキャッチし、ドメイン共通の NotFoundError や ConflictError に変換して投げることで、上位層との密結合を防ぐ。
変換ロジックは toAppError に集約し、Global Error Handler も AppError に対応させる。 - True DI の維持:
エラー処理を足しても、PrismaClient は外部から注入する。
new PrismaClient() に戻さない。 - Prisma 7 のシード:
シードコマンドは package.json ではなく prisma7.config.ts の migrations.seed に書き、prisma/seed.ts から共有の prisma を使って初期データを投入する。
次回:第16回「パスワードハッシュ化(bcrypt / Argon2)とセッション認証の仕組み」へ続く

