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にマッピングして公開フィールドを制御する:
// 悪い例: passwordHash、deletedAt、内部フィールドが漏れる
return await prisma.user.findUniqueOrThrow({ where: { id } });
// 良い例: 明示的な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クエリが発行される。
// 悪い例: 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セットでは結果セットサイズが増える可能性がある — リレーションが親ごとに多くの行を返す可能性がある場合は、両方のアプローチをベンチマークする。
コード例
カーソルページネーション(フィードや大規模データセットに推奨)
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('メールアドレスは既に存在します');
if (e.code === 'P2025') throw new NotFoundError('レコードが見つかりません');
if (e.code === 'P2003') throw new BadRequestError('参照先レコードが存在しません');
}
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はレコードではなく件数(count)を返す
// 悪い例: 結果は { count: 2 } — users[0] は undefined
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// 良い例: 最初に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秒でタイムアウトする
// 悪い例: トランザクション内の外部呼び出しがデフォルトの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 } });
});
// 良い例: 外部呼び出しはトランザクションの外で実行
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でのみ自動設定される。バルク書き込みでは古い値のままになる。
// 悪い例: updatedAtは古い値のまま
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 } });
// 悪い例: Prisma型エラー — { id, deletedAt } は一意制約ではない
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// 良い例: findFirstOrThrowは任意のwhere条件をサポートする
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
deleteManyをwhereなしで使用するとすべての行を削除する
// 悪い例: テーブルを静かに全削除
await prisma.post.deleteMany();
// 良い例
await prisma.post.deleteMany({ where: { authorId: userId } });
ベストプラクティス
| ルール | 理由 |
|---|---|
CI/CDではmigrate deploy、migrate devはローカルのみ | migrate devはドリフト時にDBをリセットする可能性がある |
| エンティティをレスポンスDTOにマッピング | 内部フィールドの漏洩を防止 |
| すべてのトランザクションのインタラクティブ形式にタイムアウトを設定 | デフォルトの5秒制限は外部呼び出しで簡単に超過する |
ソフトデリートテーブルではfindUniqueOrThrowではなくfindFirstOrThrow | findUniqueOrThrowは一意制約を強制し、deletedAtと組み合わせられない |
DB URLにクエリパラメータを埋め込む(DATABASE_URL=postgresql://...?connection_limit=1) | 文字列連結は既存のクエリパラメータを壊す |
| フィード/リストには常にカーソルページネーションを使用 | 安定したページネーション、大規模オフセットでのパフォーマンス問題なし |
includeを使用する場合はselectでリレーションフィールドを制限 | リレーションからの過剰フェッチを防止、N+1を回避 |
| 外部呼び出しをトランザクションの外に保持 | トランザクションのタイムアウトとデッドロックを防止 |
クイックリファレンス
# バージョンチェック
npx prisma --version
# 安全なマイグレーション
npx prisma migrate deploy
# ドリフトチェック(適用なし)
npx prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --shadow-database-url "$SHADOW_DATABASE_URL"
# クライアントの再生成
npx prisma generate
# スキーマのフォーマット
npx prisma format
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
// アダプターベースの初期化(新しいPrismaインストールに推奨)
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
# サーバーレスデータベースURL(PgBouncer対応)
DATABASE_URL="postgresql://user:pass@host:6543/db?pgbouncer=true&connection_limit=1&pool_timeout=20"