Prisma パターン は何をしますか?
TypeScript バックエンドにおける Prisma ORM のプロダクションパターンと非自明な落とし穴。
パターンを適用する前にバージョンを確認してください。 Prisma API の表面はメジャーリリースごとに変化しています。
npx prisma --versionバージョン間の注目すべき API の違い:
relationJoinsはリレーションを個別クエリではなく JOIN で読み込めますが、大量の 1:N リレーションや深いincludeで行爆発を起こす可能性があります — 両方のアプローチをベンチマークしてくださいomitフィールド修飾子とprisma.$extendsクライアント拡張 API が追加されました- 新しいインストール: パッケージ名が
@prisma/clientではなくprismaになっている場合があります。PrismaClientはドライバアダプター(例:@prisma/adapter-pg)を必要とする場合があります。datasource.urlがschema.prismaではなくprisma.config.tsにある場合があります- CLI コマンド(
migrate dev、migrate deploy、generate)はバージョン間で変更されていません
アクティベーション条件
- 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は、updateとupsertでのみ Prisma によって自動設定されます(一括更新の落とし穴についてはアンチパターンを参照)。
include vs select
include | select | |
|---|---|---|
| 戻り値 | すべてのスカラーフィールド+指定されたリレーション | 指定されたフィールドのみ |
| 使用するケース | ほとんどのフィールドとリレーションが必要な場合 | ホットパス、大きなテーブル、過剰取得の回避 |
| パフォーマンス | 幅広いテーブルで過剰取得の可能性あり | 最小限のペイロード、大きなデータセットで高速 |
| Prisma 5 での注意点 | デフォルトで JOIN を使用(relationJoins) | 同じ |
// include — すべてのカラム+リレーション
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — 明示的な許可リスト
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
API レスポンスから生の Prisma エンティティを決して返さないでください — レスポンス DTO にマッピングして公開フィールドを制御します:
// BAD: passwordHash、deletedAt、内部フィールドが漏洩する
return await prisma.user.findUniqueOrThrow({ where: { id } });
// GOOD: 明示的な DTO マッピング
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
トランザクション形式の選択
| 状況 | 使用する形式 |
|---|---|
| 独立した操作、相互依存なし | 配列形式 |
| 後のステップが前の結果に依存 | インタラクティブ形式 |
| 外部呼び出し(メール、HTTP)が含まれる | トランザクションの外側 |
// 配列形式 — 1 ラウンドトリップでバッチ処理
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// インタラクティブ形式 — tx クライアントのみ使用し、外側の prisma クライアントは決して使用しない
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 インスタンスは独自の接続プールを開きます。1 回だけインスタンス化します。
// lib/prisma.ts
// オプション A — アダプターベースの初期化(新しい Prisma インストールで必要)
import { PrismaClient } from '@prisma/client'; // またはセットアップに応じた生成クライアントパス
import { PrismaPg } from '@prisma/adapter-pg';
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
}
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// オプション B — 直接初期化(古いインストール、アダプター不要)
// import { PrismaClient } from '@prisma/client';
// export const prisma = globalForPrisma.prisma ?? new PrismaClient({ ... });
Prisma インストールが PrismaClient コンストラクタで adapter 引数を必要とする場合はオプション A を使用します。
new PrismaClient() が引数なしで動作する場合はオプション B を使用します。コンパイラにどちらが正しいかを判断させてください。
globalThis パターンは、ホットリロード中(Next.js、nodemon、ts-node-dev)の重複インスタンスを防止します。
N+1 問題
ループ内でリレーションを読み込むと、行ごとに 1 つのクエリが発行されます。
// BAD: N+1 — ユーザーごとに 1 つの余分なクエリ
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
// GOOD: 単一クエリ
const users = await prisma.user.findMany({ include: { posts: true } });
Prisma 5+ の relationJoins では、include 形式は単一の JOIN を使用します。大規模な 1:N セットでは結果セットのサイズが増加する可能性があります — リレーションが親ごとに多くの行を返す可能性がある場合は、両方のアプローチをベンチマークしてください。
コード例
カーソルページネーション(フィードや大規模データセットに推奨)
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 を取得してポップ — 余分なカウントクエリなしで 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 一意制約違反 · P2025 見つからない · P2003 外部キー違反。
サービス境界でキャッチしてドメインエラーに変換します。生の Prisma メッセージを API コンシューマーに公開しないでください。
接続プール — サーバーレス
接続パラメータを DATABASE_URL に直接埋め込みます — 文字列連結は、URL に既にクエリパラメータがある場合(例:?schema=public)に壊れます:
# .env — 推奨: URL にパラメータを埋め込む
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
# 外部プーラー(PgBouncer、Supabase pooler)を使用する場合
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
// Vercel、AWS Lambda、および同様のサーバーレスランタイム:
// インスタンスごとにプールを 1 に制限。connection_limit と pool_timeout は DATABASE_URL で制御
// アダプターベースのセットアップ(Prisma インストールがアダプターを必要とする場合):
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// 直接セットアップ(Prisma インストールがアダプターを必要としない場合):
// const prisma = new PrismaClient();
アンチパターン
updateMany はレコードではなく件数を返す
// BAD: 結果は { count: 2 } — users[0] は undefined
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// GOOD: 最初に ID を取得し、次に更新し、最後に影響を受けた行のみを取得
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 秒でタイムアウトする
// BAD: トランザクション内の外部呼び出しがデフォルトの 5 秒を超える → "Transaction already closed"
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 } });
});
// GOOD: 外部呼び出しはトランザクションの外側
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)を使用します:
# ステップ 1: ローカルでマイグレーションを作成し、デプロイ
npx prisma migrate dev --name add_new_column # ローカルのみ
npx prisma migrate deploy # ステージング / 本番
// ステップ 2: データのバックフィル(スクリプトまたはマイグレーションジョブで実行。シェルでは実行しない)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
# ステップ 3: NOT NULL 制約のマイグレーションをローカルで作成し、デプロイ
npx prisma migrate dev --name make_new_column_required # ローカルのみ
npx prisma migrate deploy # ステージング / 本番
@updatedAt は updateMany では機能しない
@updatedAt は update と upsert でのみ自動設定されます。一括書き込みでは古い値のままになります。
// BAD: updatedAt は古い値のまま
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
// GOOD
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});
ソフトデリート + findUniqueOrThrow は削除されたレコードを漏洩する
findUniqueOrThrow は、行が DB に存在しない場合にのみ P2025 をスローします。ソフトデリートされた行は依然として存在し、エラーなしで返されます。
findUniqueOrThrow は where に一意制約フィールドを必要とします — id と一緒に deletedAt: null を追加すると、{ id, deletedAt } は複合一意制約ではないため型エラーが発生します。代わりに findFirstOrThrow を使用します。
// BAD: ソフトデリートされたユーザーを返す
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// BAD: Prisma の型エラー — { id, deletedAt } は一意制約ではない
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// GOOD: findFirstOrThrow は任意の where 条件をサポート
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
deleteMany に where がないとすべての行が削除される
// BAD: テーブルを静かに全削除
await prisma.post.deleteMany();
// GOOD
await prisma.post.deleteMany({ where: { authorId: userId } });
ベストプラクティス
| ルール | 理由 |
|---|---|
CI/CD では migrate deploy、ローカルのみ migrate dev | migrate dev はドリフト時に DB をリセットする可能性がある |
| エンティティをレスポンス DTO にマッピング | 内部フィールドの漏洩を防ぐ |
ループ内でのクエリを避け、include を使用 | N+1 問題を防止 |
各 where 句に適切なインデックスを追加 | クエリパフォーマンスを確保 (EXPLAIN ANALYZE で確認) |
@updatedAt は update/upsert のみ自動設定。updateMany では手動で設定 | 一括更新後の updatedAt の古い値を防止 |
| マイグレーションファイルを決して手動編集しない | チェックサムエラー P3006 を防止 |
ソフトデリートモデルでは findUnique の代わりに findFirst/findFirstOrThrow を使用 | ソフトデリートされた行が findUniqueOrThrow で返されるのを防ぐ |