データベース設計は、アプリの寿命を決めます。テーブルの形・リレーションの張り方・制約の置き方を最初に間違えると、後からの修正は「マイグレーションでデータを壊さずに直す」という最高難度の作業になります。だからこそ、スキーマは最初に、正しく、意図が型に現れる形で設計する価値があります。
この記事は、Prisma ORM(v7)のスキーマとリレーション設計を本番品質で固めるための実装ガイドです。schema.prisma は単なる設定ファイルではなく、**型・クライアント・マイグレーションすべての源泉(single source of truth)**です。ここを丁寧に設計することが、その後の全コードの型安全性を決めます。Prisma 全体の本番運用はPrisma ORM 本番運用ガイド(v7)にまとめています。本記事はその中の「データモデリング」を深掘りする位置づけです。
この記事のルール:スキーマ構文・属性は Prisma 公式ドキュメント(2026年6月時点、v7系) に基づきます。題材は PostgreSQL です。プロバイダ(MySQL/SQLite/SQL Server/MongoDB/CockroachDB)により対応状況や既定のネイティブ型が異なるため、本番投入前に必ず公式ドキュメントで最新仕様を確認してください。
0. メンタルモデル:制約は「アプリの善意」ではなくDBに置く
私は型安全を徹底したプロダクトで、不正な状態をそもそも表現できないよう設計することを習慣にしています。データベースも同じです。「アプリが必ず正しい値を入れてくれる」という前提は、運用2年目には必ず破られます。一意性・NOT NULL・外部キー・参照アクションは、アプリのif文ではなくスキーマ(=DB制約)に置く——これが破綻しないデータモデリングの第一原則です。
Prisma のスキーマは、この「制約をDBに寄せる」を宣言的に書ける場所です。そして書いた制約は、そのまま型にも反映されます。String? は string | null、@unique は findUnique の引数、リレーションは include の形——スキーマの設計判断が、コードの型として返ってきます。
1. リレーションは「2フィールドで1組」
Prisma のリレーションで最初に腹落ちさせるべきは、1つのリレーションは必ず2つのフィールドで表現されるという点です。外部キー(FK)を持つ側の @relation(fields, references) と、その逆参照側。この対応を理解すれば、すべてのリレーションは同じ原理の変奏に見えてきます。
1.1 1対多(最頻出)
最も使う形です。FKは「多」の側に置きます。
model User {
id Int @id @default(autoincrement())
posts Post[] // 逆参照(リストになる)
}
model Post {
id Int @id @default(autoincrement())
author User @relation(fields: [authorId], references: [id]) // FK側
authorId Int // 実際の外部キー列
}
Post.author と Post.authorId がFKを持つ側、User.posts が逆参照側(配列)です。author を**省略可能(任意)**にしたいなら、両方に ? を付けます。
model Post {
id Int @id @default(autoincrement())
author User? @relation(fields: [authorId], references: [id])
authorId Int? // 著者なしの投稿を許す
}
1.2 1対1
1対多との違いはたった1つ——FK列に @unique を付けることです。「1ユーザーに最大1プロフィール」を、一意制約で型・DBの両方に強制します。
model User {
id Int @id @default(autoincrement())
profile Profile? // FKを持たない側は任意(?)にする
}
model Profile {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int @unique // ← これが 1対1 の鍵
}
userId @unique がなければ、これはただの1対多です。「1対1か1対多か」は @unique の有無で決まる——この一点を覚えておけば設計で迷いません。
2. 多対多:暗黙か、明示か
多対多(記事⇄タグなど)には2つの設計があります。判断軸は「中間テーブルそのものに属性を持たせたいか」だけです。
2.1 暗黙(Prisma管理の中間テーブル)
中間テーブルに余分な列が要らないなら、Prisma に任せます。両モデルにリストを置くだけで、Prisma が中間テーブル(例 _CategoryToPost)を自動生成します。
model Post {
id Int @id @default(autoincrement())
title String
categories Category[]
}
model Category {
id Int @id @default(autoincrement())
name String
posts Post[]
}
暗黙の中間テーブルは、両モデルが単一の @id を持つことが条件で、fields / references / onDelete / onUpdate は指定できません。シンプルな関連には最適です。
2.2 明示(中間モデルを自分で定義)
「いつ・誰が紐付けたか」のような関連自体の属性が必要なら、中間モデルを明示します。これが実務では圧倒的に多い形です。
model Post {
id Int @id @default(autoincrement())
title String
categories CategoriesOnPosts[]
}
model Category {
id Int @id @default(autoincrement())
name String
posts CategoriesOnPosts[]
}
model CategoriesOnPosts {
post Post @relation(fields: [postId], references: [id])
postId Int
category Category @relation(fields: [categoryId], references: [id])
categoryId Int
assignedAt DateTime @default(now()) // 関連の属性
assignedBy String
@@id([postId, categoryId]) // 複合主キー
}
@@id([postId, categoryId]) で複合主キーを宣言し、「同じ組み合わせは1回だけ」を保証します。assignedAt / assignedBy のような監査情報を関連に持たせられるのが明示形の強みです。
設計判断(YAGNI):迷ったら暗黙から始め、関連に属性が必要になった瞬間に明示へ移行します。先回りして全部を明示モデルにすると、ボイラープレートだけが増えます。
3. 参照アクション:既定値を信じず、意図を明示する
親レコードを削除/更新したとき、子をどうするか——onDelete / onUpdate で宣言します。ここは既定値が直感に反することがあり、事故の温床です。
model Post {
id Int @id @default(autoincrement())
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
}
指定できる値は Cascade / Restrict / NoAction / SetNull / SetDefault の5つ。省略時の既定は、リレーションが任意か必須かで変わります。
| 句 | 任意リレーション(FKが?) | 必須リレーション |
|---|---|---|
onDelete | SetNull | Restrict |
onUpdate | Cascade | Cascade |
要点を3つ。
onDelete: Cascadeは「親を消したら子も消す」。コメントや明細など「親に従属する子」には妥当ですが、監査ログや決済記録のような『消えてはいけない子』に安易に付けないこと。SetNullは FK が任意(?)でなければ使えません。SetDefaultは FK 列に@defaultが要ります。- 既定の
Restrict(親に子が残っていると削除を拒否)は安全側ですが、「なんとなく既定」ではなく、毎回onDeleteを明示するのを推奨します。意図がスキーマに現れ、レビューで判断できます。
可搬性の注意:
Restrictは SQL Server では使えずNoActionを使います。自己参照や循環参照も SQL Server / MongoDB ではNoActionが必要です。
4. relationMode:FKをDBで持つか、Prismaで持つか
relationMode は、参照整合性をDBの外部キー制約で守るか、Prisma Client のクエリロジックで疑似的に守るかの選択です。
// 既定:本物のFK制約をDBに張る(PostgreSQL等)
datasource db {
provider = "postgresql"
relationMode = "foreignKeys"
}
// FK非対応/無効なDB向け(PlanetScale、シャーディング等)
datasource db {
provider = "mysql"
relationMode = "prisma"
}
foreignKeys(既定):DBレベルで本物のFK制約と参照アクションを効かせます。整合性をDBが保証するので、通常はこれ一択です。prisma:FKをサポートしない/無効化した環境(MongoDB、外部キーを張らない PlanetScale など)で、Prisma が整合性をエミュレートします。
prisma モードの最重要の落とし穴:このモードでは Migrate / db push がFK列のインデックスを自動生成しません。インデックスのないFKは結合のたびにフルスキャンを招き、遅く・高コストになります。自分で @@index を張る必要があります。
model Post {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id])
@@index([userId]) // prismaモードでは必須
}
5. ID・制約・既定値
5.1 主キーと複合制約
model User {
id Int @id @default(autoincrement())
email String @unique
}
// 複合主キー
model Member {
orgId Int
userId Int
role String
@@id([orgId, userId])
}
// 複合ユニーク・複合インデックス
model Post {
id Int @id @default(autoincrement())
authorId Int
title String
@@unique([authorId, title]) // 同一著者の同名記事を禁止
@@index([authorId, title]) // 検索を高速化
}
@id / @unique は列レベル、@@id / @@unique / @@index は複数列をまとめるブロックレベルです。すべてのモデルは単一 @id か複合 @@id のいずれかで一意識別子を持つ必要があります。
5.2 既定値の生成関数
model Post {
id Int @id @default(autoincrement())
uuid String @default(uuid(7)) // 時系列ソート可能なUUIDv7
cuid String @default(cuid(2)) // 衝突しにくい短いID
createdAt DateTime @default(now())
published Boolean @default(false)
}
代表的な関数は autoincrement() / cuid()(cuid(2) も可)/ uuid()(uuid(4) / uuid(7))/ now() / dbgenerated(...) など。主キー戦略は設計判断です——分散環境やURLに出すIDは連番より uuid(7) / cuid(2)(推測されにくく、かつ時系列性を持てる)が安全側に倒れます。DB固有のデフォルト式が必要なときは dbgenerated("...") でDB側に委ねます。
6. スカラー型・ネイティブ型・enum・自己参照
6.1 型とネイティブ型
Prisma のスカラー型は String / Boolean / Int / BigInt / Float / Decimal / DateTime / Json / Bytes の9種。@db.* でDBの物理型を上書きできます。
model Article {
id Int @id @default(autoincrement())
slug String @db.VarChar(200) // text ではなく varchar(200)
body String @db.Text
price Decimal // 金額は Float ではなく Decimal(丸め誤差を避ける)
metadata Json // PostgreSQL では jsonb にマップ
tags String[] // スカラーのリスト(PostgreSQL/CockroachDB/MongoDB)
}
設計のコツ:金額・数量など正確性が要る値に
Floatを使わないこと。Decimal(または整数のマイナー単位)にします。私は決済基盤で金額を整数のマイナー単位に統一し、丸め誤差を構造的に排除しました。型の選択は、そのまま正しさの設計です。
6.2 enum と自己参照
enum Role {
USER
ADMIN
}
model User {
id Int @id @default(autoincrement())
role Role @default(USER)
// 自己参照(1対多):上司と部下
managerId Int?
manager User? @relation("OrgChart", fields: [managerId], references: [id])
reports User[] @relation("OrgChart")
}
自己参照は、両側が同じ @relation("名前") を共有することで Prisma がペアを認識します。enum は PostgreSQL/MySQL/MongoDB/CockroachDB でDBの列挙型にマップされます(SQLite/SQL Server はネイティブ非対応)。
7. 既存DBへ後付け導入する:@map と db pull
新規ではなく既存DBに Prisma を載せるケースは多く、ここで @map / @@map が効きます。コード上の名前(キャメルケース等)と物理名(スネークケース等)を分離できます。
model User {
id Int @id @default(autoincrement())
firstName String @map("first_name") // 列名は first_name
posts Post[]
@@map("users") // テーブル名は users
}
既存DBからスキーマを起こすには、まずイントロスペクションで現状を取り込みます。
npx prisma db pull # 既存DB → schema.prisma を生成(上書き)
npx prisma generate # 型付きクライアントを生成
db pull で生成された素のスキーマに対し、@map でアプリ側の命名を整え、リレーションを @relation で明示していく——これが既存DB導入の定石です。ベースライン(既存DBを壊さずマイグレーション履歴を開始する手順)はPrisma Migrate 本番運用ガイドで詳述します。
マルチスキーマ:PostgreSQL/SQL Server では
@@schema("public")でモデルを特定スキーマに割り当てられます(multiSchema機能と datasource のschemas指定が前提)。
8. スキーマ設計チェックリスト
本番に出す前に、私がスキーマで必ず確認する項目です。
- すべてのモデルが
@idか@@idで一意識別子を持つ - リレーションは「FK側
@relation(fields, references)+逆参照側」が両方揃っている - 1対1は FK 列に
@uniqueが付いている(付け忘れ=実質1対多) - 多対多は「中間テーブルに属性が要るか」で暗黙/明示を選べている
-
onDelete/onUpdateを毎回明示(特に Cascade を付けてよい子かを判断)。監査・決済の子に安易な Cascade なし - FK 列・検索条件列に
@@index(relationMode = "prisma"なら必須) - 金額・数量は
Decimalか整数のマイナー単位(Floatを避ける) - URL/外部公開IDは連番ではなく
uuid(7)/cuid(2)を検討 - 既存DBは
@map/@@mapでコード名と物理名を分離し、db pullで現状を取り込む - 一意制約は複合
@@uniqueまで設計し、冪等性(upsertのキー)に使えるようにする
まとめ
Prisma のスキーマ設計は、「制約をアプリではなくDBに置き、その意図を型に現れる形で宣言する」作業です。リレーションは2フィールドで1組、1対1は @unique、多対多は属性の有無で暗黙/明示、参照アクションは既定に頼らず明示、relationMode = "prisma" ではインデックスを自分で張る——この設計判断の積み重ねが、後から壊れないデータモデルを作ります。
スキーマが固まれば、次はそれをどう本番DBへ安全に適用し続けるかです。無停止のスキーマ変更とCI/CDはPrisma Migrate 本番運用ガイドへ。「データモデルの設計から、本番で壊れないDB運用までを一気通貫で固めたい」——そんな要件があれば、設計レビューから実装までお手伝いできます。