NestJS × Prismaで深めるDB設計 ─ モデル・リレーション・運用設計

  • prisma
    prisma
  • nestjs
    nestjs
2024/09/12に公開

はじめに

ここまでの記事では、NestJSとPrismaを使って、記事投稿APIの基本的なCRUD処理を実装し、ログ出力やエラーハンドリング、テスト環境の整備に取り組んできました。

第4回となる今回は、Prismaを使ったデータベース設計にもう一歩踏み込み、
複数のモデル定義やリレーション設計、マイグレーション管理といった、より実践的な運用を視野に入れた内容を整理していきます。

また、PrismaClientのインスタンス設計(PrismaServiceとPrismaModuleによるDI対応)についてはすでに導入済みのため、今回はその振り返りを踏まえつつ、さらに複数モデル間の関係性や、開発〜本番を意識したデータベース運用まで広げていく流れとします。

本記事の対象読者は、以下のような方を想定しています。

  • NestJSとPrismaを使ったバックエンド開発をこれから本格的に進めたい方
  • データベース設計にまだ不安があり、リレーション定義やマイグレーション管理のベストプラクティスを押さえておきたい方
  • Prismaを導入したものの、複数モデル間の連携や運用方針に悩んでいる方

なお、この記事は第3回までの記事を踏まえた続きの内容となっています。
もし未読の場合は、まず第1回から第3回までを順に読んでいただくことをおすすめします。

第1回

https://shinagawa-web.com/blogs/nestjs-blog-series-setup-and-config

第2回

https://shinagawa-web.com/blogs/nestjs-blog-series-crud-and-prisma-intro

第3回

https://shinagawa-web.com/blogs/nestjs-blog-series-logging-error-testing

この連載の全体像と今回の位置づけ

連載構成

  1. NestJSで始めるWebアプリ開発 ─ ブログサイトを作りながら学ぶプロジェクト構成と設定管理
  2. NestJSで記事投稿APIを作ろう ─ Prisma導入とCRUD実装の基本
  3. アプリの信頼性を高める ─ ロギング・エラーハンドリング・テスト戦略
  4. PrismaとDB設計を深掘る ─ モデル・リレーション・運用設計 ← 今回の記事
  5. ReactでUIを構築して本番へデプロイ ─ Dockerと環境構築

Prismaで複数モデルを定義する

この章では、実際に複数のモデルをPrismaスキーマファイルに定義する方法を整理します。

アプリケーションが少しずつ大きくなっていく中で、単一のモデルだけでデータを管理し続けることは現実的ではありません。
User、Post、Commentといったように、複数のモデルが存在し、それらの関係性を適切に設計することが必要になります。

今回は、シンプルな例として User モデルと Post モデルを定義してみます。

UserモデルとPostモデルの例

Prismaのスキーマファイルに、次のように定義します。

model User {
  id        Int     @id @default(autoincrement())
  email     String  @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String
  authorId  Int
  author    User    @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

このように、UserPost の間に1対多(1:N)のリレーションを定義しています。
1人のユーザーが複数の投稿を持ち、各投稿は必ず1人のユーザーに紐づく構成です。

型補完を活かしたモデル定義のすすめ

Prismaの良さは、スキーマファイルを書いた時点で、
TypeScript側にも型が即座に生成されることにあります(Prisma Client)。

モデルを定義すると、次のような型安全な補完が利用できるようになります。

const newPost = await prisma.post.create({
  data: {
    title: 'My first post',
    content: 'Hello World!',
    author: {
      connect: { id: 1 },
    },
  },
});

このとき、フィールド名や型ミスはコンパイル時点で検出されるため、ランタイムエラーのリスクが大きく減ります。
モデル設計の段階で、フィールド名や型の意図を丁寧に整理しておくことが、後の開発体験を大きく左右します。

Prismaスキーマ上の命名・命令のコツ

Prismaスキーマでは、次のような小さな工夫が長期的な開発効率に繋がります。

  • モデル名は単数形で書く(例:User, Post, Comment
  • createdAt, updatedAt を標準で持たせる
  • リレーションフィールドには複数形を使う(例:User.posts
  • optionalなフィールド(?)と必須フィールドを明確に分ける

これらを意識することで、スキーマファイル自体がドキュメントとしても機能し、
後からプロジェクトに参加するメンバーにも理解しやすいデータ構造を提供できます。

命名にまつわるよくある失敗例

一方で、モデル定義の初期段階でありがちな命名ミスとして、次のようなケースも見られます。

  • モデル名を複数形にしてしまう
    例:Users, Posts
    → PrismaClientで prisma.users.findMany() のように違和感のあるコードになってしまいます。

  • リレーションフィールドを単数形にしてしまう
    例:User.post: Post[]
    → 実態は複数なのに単数名のため、コードリーディング時に混乱を招きます。

  • フィールド名に意図が伝わらない一般名詞を使う
    例:User.dataPost.info
    → 「何を指すのか」が不明瞭で、後からスキーマを読む負担が増えます。

このようなミスを防ぐためにも、モデル単位では単数形、リスト型のリレーションでは複数形、フィールド名はできるだけ具体的に、を意識して設計を進めることが大切です。

リレーションの定義と注意点

複数のモデルが登場するアプリケーションでは、それぞれのモデル同士をどのように結びつけるか(リレーション設計)が非常に重要になります。

ここでは、Prismaでリレーションを定義する基本と、設計時に注意すべきポイントについて整理していきます。

@relationfields, references の書き方

Prismaでは、リレーションを定義する際に @relation 属性を使います。

たとえば、Post モデルが User モデルに紐づく例は次のように書きます。

model User {
  id    Int    @id @default(autoincrement())
  name  String
  posts Post[]
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  authorId Int
  author   User   @relation(fields: [authorId], references: [id])
}

ここでのポイントは、

  • Post側が 外部キー(authorId)を持つ
  • @relation(fields: [...], references: [...]) で どのカラム同士が繋がるか明示する

という点です。

Prismaでは、このfieldsreferencesの明示がとても大切です。
これを省略すると、意図しないリレーションが組まれてしまったり、マイグレーションエラーが発生しやすくなります。

1対多(1:N)、1対1、N対N の設計パターン

リレーションにはいくつかのパターンがあります。

  • 1対多(1:N)
    例:User が複数の Post を持つ

  • 1対1(1:1)
    例:User に1つだけ Profile を紐付ける

  • 多対多(N:N)
    例:Post に複数の Tag が紐づき、Tag も複数の Post に紐づく

それぞれに適したリレーション設計が求められます。
特にN:Nの場合は、中間テーブルを自動生成するか、手動で中間モデルを作成するかを意図的に選択することが重要です。

onDelete, onUpdate の活用例と考慮点

リレーションの設計では、親モデルが削除されたときに子モデルをどう扱うかを考えておく必要があります。

Prismaでは、@relation 属性にオプションで onDelete や onUpdate を指定できます。

例:

author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)
  • Cascade
    親が削除されたら子も自動で削除される(例:User削除時にPostも削除)

  • Restrict
    子が存在する限り親を削除できない(デフォルト)

  • SetNull
    親が削除されたら子の外部キーをnullにする

運用方針によって適切な設定を選ぶことが大切です。
特にCascadeを使う場合、誤って親データを消すと大量の子データも消えるため、十分な注意が必要です。

Prisma特有のリレーション定義ミスあるある

Prismaでは、リレーション設定ミスによる以下のようなトラブルがよく起こります。

  • 片側だけにフィールドを定義してしまう
    (PostにauthorIdはあるけど、Userにpostsがない、など)

  • fieldsとreferencesの指定漏れでマイグレーションエラー

  • N:Nリレーションで自動中間テーブル生成に気づかず混乱する

こういったミスを防ぐためにも、

  • リレーションは必ず「両側から辿れる」形を基本とする
  • 明示的なfields/references指定を欠かさない

という意識が重要になります。

次の章では、こうして定義したモデル・リレーションをどのようにマイグレーション管理していくか、そして開発〜本番フローでDBをどう扱うかについて整理していきます。

Prismaのマイグレーション戦略

モデルを設計した後は、それを実際のデータベースに反映する必要があります。
このプロセスを管理するのが、Prismaのマイグレーション機能です。

この章では、Prismaのマイグレーションコマンドの使い分けと、運用時に気をつけたいポイントを整理していきます。

migrate dev, db push, migrate deploy の違いと使い分け

Prismaにはいくつかのスキーマ反映コマンドが用意されています。それぞれの役割と使い分けは次の通りです。

コマンド 主な用途 特徴
npx prisma migrate dev ローカル開発用 マイグレーションファイルを生成し、DBに適用。履歴管理あり
npx prisma db push テスト環境・PoC用 直接スキーマをDBに反映。履歴管理なし
npx prisma migrate deploy 本番用 すでに生成されたマイグレーションを本番DBに適用

開発中は主に migrate dev を使い、
テスト環境では手軽に試すために db push を使う場面もあります。

本番環境では必ず migrate deploy を使用し、履歴の一貫性を守る運用が推奨されます。

マイグレーション履歴(migrations/)の扱い

migrate dev を実行すると、prisma/migrations/ ディレクトリにマイグレーションファイルが生成されます。

これらのファイルは、コードと同様にGitで管理します。
つまり、スキーマの進化履歴もソースコードの一部として扱うイメージです。

ここでの注意点は、

  • マイグレーションファイルを勝手に編集しない
  • 不要なマイグレーション(やり直し)は、基本的に作り直すか、明示的にリセットする

という運用ルールを持つことです。

schema.prisma の編集と git 運用の注意点

スキーマファイル(schema.prisma)を編集したら、以下の流れを基本とします。

  1. スキーマ編集(フィールド追加・削除・修正など)
  2. npx prisma migrate dev でマイグレーションファイルを生成
  3. 動作確認(DBが期待通り更新されているか)
  4. 変更内容をGitにコミット(スキーマとマイグレーション両方)

このとき、スキーマだけを変更してマイグレーションを生成しないままコミットしてしまうと、
あとから環境を再現できなくなるリスクがあります。

そのため、「schema.prismaを修正したら必ずmigrateする」という習慣を意識して運用していきます。

PrismaClientのインスタンス設計をふり返る

ここでは、これまでに導入してきたPrismaClientのインスタンス設計について、あらためて整理します。

PrismaClientは、データベースと直接やりとりする重要なコンポーネントですが、NestJSのようなフレームワークと統合する際には、ライフサイクル管理(接続・切断)を意識した設計が求められます。

DI対応のポイントと PrismaService の構成

NestJSでは、依存関係の注入(DI: Dependency Injection)を活用して各コンポーネントを疎結合に保ちます。
PrismaClientも例外ではなく、サービスクラス(PrismaService)としてDI対応させるのが基本です。

作成した PrismaService は次のような形になっています。

import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect(); // アプリ起動時にDB接続
  }

  async onModuleDestroy() {
    await this.$disconnect(); // アプリ終了時にDB切断
  }
}

この構成にすることで、

  • アプリケーションの起動時に自動でDBに接続
  • シャットダウン時にDB接続を安全に閉じる

といったライフサイクル管理が自動化されます。

PrismaModuleでの再利用設計

PrismaServiceを各コンポーネントで使い回すために、PrismaModuleを作成し、
NestJSのimportsに登録して利用できるようにしました。

PrismaModuleはとてもシンプルで、次のようになっています。

import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

これによって、たとえば PostsModule 側ではこうインポートできます。

@Module({
  imports: [PrismaModule],
  providers: [PostsService],
})
export class PostsModule {}

この設計により、

  • PrismaServiceを複数のモジュール間で安全に共有できる
  • PrismaServiceの実装が変更されても、依存先を意識せずに差し替えできる

という柔軟性の高い構成が実現できました。

onModuleInit / onModuleDestroy による接続制御

PrismaService内で OnModuleInit と OnModuleDestroy を実装したことによって、

  • アプリ起動後すぐにDB接続する
  • アプリ停止時に接続をクリーンに閉じる

という動きが保証されています。

これにより、たとえばE2Eテストでアプリを立ち上げたり終了させたりするたびに、不要なコネクションが残り続ける問題を防げるようになっています。

小さな仕組みですが、これがあるだけでシステム全体の安定性と運用のしやすさが大きく向上します。

環境変数とDB接続の切り替え

開発環境・テスト環境・本番環境など、アプリケーションが動作する環境によって、
接続するデータベースや使用する設定を適切に切り替える必要があります。

この章では、環境ごとにDB接続先を分ける方法と、NestJSとPrismaでの実践的な切り替え方を整理します。

E2E / CI環境との接続先の分離とその意義

テスト環境(特にE2EテストやCI環境)では、「開発用DBを壊してしまう」「本番データにアクセスしてしまう」などの事故を防ぐために、専用のテストDBにだけ接続する構成を整えた方がいいかと思います。

これによって、

  • テスト失敗時にデータをリセットできる
  • テストが他の環境に影響を与えない
  • 本番・開発環境を安全に守れる

といったメリットが得られます。

一見地味な設計に見えますが、長期運用を考えると非常に大きな意味を持ちます。

テスト環境でのマイグレーションと初期化

テスト用のデータベースを用意したら、次はそのデータベースに必要なスキーマを適用し、テストのたびにクリーンな状態を維持する仕組みを整えます。

この章では、テスト環境でのマイグレーションの適用方法と、テスト前にデータベースを初期化する実践的な流れについて整理します。

db push or migrate deploy のタイミング

テスト環境のスキーマ整備には、状況に応じて次の2つのコマンドを使い分けます。

コマンド 主な用途 特徴
npx prisma db push E2Eテスト前の手軽なスキーマ適用 マイグレーションファイルを経由せず、直接DBに適用
npx prisma migrate deploy 本番運用を意識したスキーマ適用 生成済みマイグレーションファイルを順に適用

E2Eテストでは、素早くスキーマを反映したい場面が多いため、db push を使う場面が多くなります。

一方で、CI/CDパイプラインや本番環境では、履歴一貫性を保つために migrate deploy を使う運用が基本となります。

テスト前にデータベースをリセットする

テスト環境では、テスト実行のたびにデータベースをクリーンな状態に戻すことが非常に重要です。

一例として、test/utils/db.ts などにリセット用のヘルパー関数を用意しておきます。

test/utils/db.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export async function resetDatabase() {
  await prisma.post.deleteMany();
  // 必要に応じて他のテーブルもリセット
}

テストコード内では、beforeAll でこれを呼び出します。

beforeAll(async () => {
  await resetDatabase();
});

これにより、

  • どのテストも独立したクリーンな状態で始まる
  • テストの結果に前のテストの副作用が影響しない

という、健全なテスト環境が作られます。

初期データ(seed)の扱いとテストでの注意点

テストケースによっては、データベースが空の状態ではなく、ある程度の初期データが存在していることを前提にテストを行いたい場合があります。

たとえば、次のような状況です。

  • 記事一覧を取得するテストをしたいので、事前に数件の記事が登録されている必要がある
  • ユーザー認証が必要なテストでは、事前にテスト用ユーザーアカウントを作成しておく必要がある

こういった場合、テストのセットアップ時にテスト用の最小限のデータを作成します。

具体例:テスト用の初期データを挿入する

たとえば、resetDatabase() 関数の中で、リセット後に初期データを作成します。

import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export async function resetDatabase() {
  // 全テーブルをリセット
  await prisma.post.deleteMany();
  await prisma.user.deleteMany();

  // 必要な初期データを作成
  const user = await prisma.user.create({
    data: {
      email: 'test@example.com',
      name: 'Test User',
    },
  });

  await prisma.post.createMany({
    data: [
      { title: 'First Post', content: 'Content 1', authorId: user.id },
      { title: 'Second Post', content: 'Content 2', authorId: user.id },
    ],
  });
}

このようにしておくと、どのテストも「特定のユーザーと複数の記事が存在している状態」から確実にスタートできるため、毎回のテスト実行における前提条件がブレなくなります。

✅ 初期データ設計で意識するポイント

  • 必要最低限のデータだけを作成する
    テスト対象に無関係なデータを大量に作ると、テストが遅くなったり、失敗原因がわかりにくくなります。

  • 「何が存在しているか」を常に明示できる状態にする
    テストコード側で「id: 1のユーザーがいる前提」などが自然に読める構成にします。

  • resetseed を常にセットで動かす設計にする
    テスト間の副作用を完全に排除し、テストごとに独立した世界を作ります。

この初期データ管理の意識ができていると、テストの読みやすさ、メンテナンスのしやすさ、失敗時のデバッグ容易さが大きく向上します。
テストの信頼性を支える、地味ですが非常に大切な部分です。

おわりに

ここまでで、NestJSとPrismaを組み合わせた開発において、
より実践的なデータベース設計・リレーション定義・マイグレーション運用の基本を一通り整理してきました。

単に動くAPIを作るだけでなく、

  • モデル同士の関係性を丁寧に設計すること
  • PrismaClientをNestJSと自然に統合すること
  • 開発環境・テスト環境・本番環境ごとに接続先を切り替えること
  • テスト環境ではクリーンな状態を維持すること

といった「運用を意識した設計力」を少しずつ積み上げることができたと感じています。

今回取り上げた内容は、一つ一つは地味な作業に見えるかもしれません。
しかし、これらを丁寧に積み重ねておくことが、プロジェクトが成長したときの大きな差につながります。

次回は、いよいよフロントエンド(React)との接続と、本番環境へのデプロイ構成に進んでいきます。
APIだけでなく、実際にブラウザから触れる形にしていく過程を一緒に進めていきましょう。

https://shinagawa-web.com/blogs/nestjs-blog-series-react-deploy

Xでシェア
Facebookでシェア
LinkedInでシェア

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する技術ブログ

弊社の技術支援サービス

お問い合わせ

経営と現場をつなぐ“共創型”の技術支援。
成果に直結するチーム・技術・プロセスを共に整えます。

お問い合わせ