| name | prisma-patterns |
| description | TypeScriptバックエンド向けPrisma ORMのパターン — スキーマ設計、クエリ最適化、トランザクション、ページネーション、およびupdateManyがレコードではなくカウントを返す、$transactionのタイムアウト、migrate devがDBをリセットする、バルク書き込みで@updatedAtがスキップされる、サーバーレス環境でのコネクション枯渇といった重大なトラップ。 |
| origin | ECC |
Prisma パターン
TypeScriptバックエンドにおけるPrisma ORMの本番パターンと非自明なトラップ。
Prisma 5.x および 6.x に対して検証済み。一部の動作はPrisma 4と異なります。
バージョン固有のパターンを適用する前に、Prismaのバージョンを確認してください:
npx prisma --version
Prisma 5ではrelationJoinsが導入され、クエリ戦略と設定に応じてJOINを通じてリレーションをロードできるようになりました。omitフィールド修飾子とprisma.$extends クライアント拡張APIも追加されました。注意:relationJoinsは大規模な1:Nリレーションや深いネストのincludeでrow explosionを引き起こす可能性があります — リレーションが親ごとに多数の行を返す可能性がある場合は両方のアプローチをベンチマークしてください。
有効化するタイミング
- Prismaスキーマのモデルとリレーションを設計または変更する場合
- クエリ、トランザクション、またはページネーションロジックを記述する場合
updateMany、deleteMany、またはバルク操作を使用する場合
- データベースのマイグレーションを実行または計画する場合
- サーバーレス環境(Vercel、Lambda、Cloudflare Workers)にデプロイする場合
- ソフトデリートやマルチテナントの行フィルタリングを実装する場合
コアコンセプト
IDストラテジー
| ストラテジー | 使用タイミング | 避けるべき場合 |
|---|
@default(cuid()) | デフォルトの選択肢 — URLセーフ、ソート可能、衝突なし | 外部システムに連番IDが必要な場合 |
@default(uuid()) | Prisma以外のシステムとの相互運用性が必要な場合 | 高書き込みテーブル(ランダムUUIDはB-treeインデックスを断片化する) |
@default(autoincrement()) | 内部結合テーブル、監査ログ | 公開向けID(レコード数が露出する) |
スキーマのデフォルト
model User {
id String @id @default(cuid())
email String @unique // @uniqueはすでにインデックスを作成する — @@indexは不要
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([deletedAt, createdAt]) // ソフトデリート + ソートクエリのための複合インデックス
}
WHEREまたはORDER BYで使用されるすべての外部キーとカラムに@@indexを追加してください。
- ソフトデリートが将来的に必要となる場合は、事前に
deletedAt DateTime?を宣言してください — 後から追加するとライブテーブルでのマイグレーションが必要になります。
updatedAt @updatedAtはPrismaがupdateとupsertのみで自動的に設定します(バルク更新のトラップについてはアンチパターンを参照)。
include vs select
| include | select |
|---|
| 返却内容 | すべてのスカラーフィールド + 指定されたリレーション | 指定されたフィールドのみ |
| 使用場面 | ほとんどのフィールドとリレーションが必要な場合 | ホットパス、大規模テーブル、オーバーフェッチを避ける場合 |
| パフォーマンス | 広いテーブルでオーバーフェッチの可能性 | 最小ペイロード、大規模データセットで高速 |
| Prisma 5の注意 | デフォルトでJOINを使用(relationJoins) | 同じ |
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
APIレスポンスからRawなPrismaエンティティを返さないでください — 公開フィールドを制御するためにレスポンスDTOにマッピングしてください:
return await prisma.user.findUniqueOrThrow({ where: { id } });
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
トランザクション形式の選択
| 状況 | 使用方法 |
|---|
| 独立した操作で相互依存なし | 配列形式 |
| 後のステップが前の結果に依存する | インタラクティブ形式 |
| 外部呼び出し(メール、HTTP)が含まれる | トランザクションの外側で実行 |
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
const post = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
if (user.role !== 'ADMIN') throw new Error('Forbidden');
return tx.post.create({ data: { title, authorId: user.id } });
});
PrismaClientシングルトン
各PrismaClientインスタンスは独自のコネクションプールを開きます。一度だけインスタンス化してください。
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
globalThisパターンはホットリロード(Next.js、nodemon、ts-node-dev)時の重複インスタンスを防ぎます。
N+1問題
ループ内でリレーションをロードすると、行ごとに1つのクエリが発行されます。
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
const users = await prisma.user.findMany({ include: { posts: true } });
Prisma 5+のrelationJoinsでは、include形式が単一のJOINを使用します。大規模な1:Nセットではresult setのサイズが増加する場合があります — リレーションが親ごとに多数の行を返す可能性がある場合は両方のアプローチをベンチマークしてください。
コード例
カーソルページネーション(フィードと大規模データセットに推奨)
async function getPosts(cursor?: string, limit = 20) {
const items = await prisma.post.findMany({
where: { published: true },
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' },
],
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasNextPage = items.length > limit;
if (hasNextPage) items.pop();
return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null };
}
limit + 1を取得してpopする — 追加のカウントクエリなしにhasNextPageを検出する標準的な方法です。複数の行が同じタイムスタンプを共有する場合の不安定なページネーションを防ぐため、常に一意のフィールド(例:id)をセカンダリorderByに含めてください。ユーザーが任意のページにジャンプする必要がある場合のみオフセットページネーションを使用してください(管理テーブル)。
ソフトデリート
const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
await prisma.user.update({ where: { id }, data: { deletedAt: null } });
エラーハンドリング
import { Prisma } from '@prisma/client';
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('Email already exists');
if (e.code === 'P2025') throw new NotFoundError('Record not found');
if (e.code === 'P2003') throw new BadRequestError('Referenced record does not exist');
}
throw e;
}
一般的なコード: P2002 unique違反 · P2025 not found · P2003 外部キー違反。
サービス境界でキャッチしてドメインエラーに変換してください。APIコンシューマーにRawなPrismaメッセージを公開しないでください。
コネクションプール — サーバーレス
接続パラメータをDATABASE_URLに直接埋め込んでください — URLにすでにクエリパラメータがある場合(例:?schema=public)、文字列連結が壊れます:
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
const prisma = new PrismaClient();
アンチパターン
updateManyはレコードではなくカウントを返す
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
const targets = await prisma.user.findMany({
where: { role: 'GUEST' },
select: { id: true },
});
const ids = targets.map((u) => u.id);
await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } });
const updated = await prisma.user.findMany({ where: { id: { in: ids } } });
deleteManyも同様 — { count: n }を返し、削除された行は返しません。
$transactionインタラクティブ形式は5秒後にタイムアウトする
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await tx.user.update({ where: { id }, data: { emailSent: true } });
});
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await prisma.user.update({ where: { id }, data: { emailSent: true } });
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
migrate devはデータベースをリセットする可能性がある
migrate devはスキーマのドリフトを検出し、DBのリセットを促す場合があり、すべてのデータが削除されます。
npx prisma migrate dev --name add_column
npx prisma migrate deploy
npx prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--shadow-database-url "$SHADOW_DATABASE_URL"
マイグレーションファイルを手動編集すると将来のデプロイが壊れる
Prismaはすべてのマイグレーションファイルにチェックサムを付けます。適用後に編集すると、元のファイルがすでに実行されているすべての環境でP3006 checksum mismatchが発生します。代わりに新しいマイグレーションを作成してください。
破壊的なスキーマ変更にはマルチステップのマイグレーションが必要
既存のカラムにNOT NULLを追加したり、1つのマイグレーションでカラム名を変更したりすると、テーブルがロックされたりデータが失われたりします。expand-and-contractを使用してください:
npx prisma migrate dev --name add_new_column
npx prisma migrate deploy
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
npx prisma migrate dev --name make_new_column_required
npx prisma migrate deploy
@updatedAtはupdateManyでは発火しない
@updatedAtはupdateとupsertのみで自動的に設定されます。バルク書き込みでは古い値のままになります。
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});
ソフトデリート + findUniqueOrThrowは削除済みレコードを漏洩させる
findUniqueOrThrowはDBに行が存在しない場合にのみP2025をスローします。ソフトデリートされた行はまだ存在しており、エラーなしで返されます。
findUniqueOrThrowはwhereにユニーク制約フィールドが必要です — idの横にdeletedAt: nullを追加すると、{ id, deletedAt }が複合ユニーク制約ではないため型エラーが発生します。代わりにfindFirstOrThrowを使用してください。
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
whereなしのdeleteManyはすべての行を削除する
await prisma.post.deleteMany();
await prisma.post.deleteMany({ where: { authorId: userId } });
ベストプラクティス
| ルール | 理由 |
|---|
CI/CDではmigrate deploy、ローカルのみmigrate dev | migrate devはドリフト時にDBをリセットする可能性がある |
| エンティティをレスポンスDTOにマッピングする | 内部フィールドの漏洩を防ぐ |
サービス境界でPrismaClientKnownRequestErrorをキャッチする | ドメインエラーに変換する |
手動のnullチェックより*OrThrowメソッドを優先する | P2025を自動的にスロー;非ユニークフィールドをフィルタリングする場合はfindFirstOrThrowを使用 |
サーバーレスではconnection_limit=1 + 外部プーラー | コネクション枯渇を防ぐ |
deleteManyには常にwhereを指定する | 誤ったテーブル全消去を防ぐ |
updateManyではupdatedAt: new Date()を手動で設定する | @updatedAtはバルク書き込みをスキップする |
関連スキル
nestjs-patterns — Prismaを統合するNestJSサービスレイヤー
postgres-patterns — PostgreSQLレベルのインデックスとコネクションチューニング
database-migrations — 本番環境向けマルチステップのマイグレーション計画
backend-patterns — 一般的なAPIとサービスレイヤーの設計