レビュー待ち·難易度: 中級·更新: 2026-04-18

Prisma で論理削除(soft delete)を実装する

deletedAt フィールドを使った論理削除パターンを Prisma で実装し、通常クエリから削除済みレコードを自動除外する例。

nextjscrudprisma

対応バージョン

nextjs 15react 19prisma 5

前提環境

Prisma の基本スキーマ定義と CRUD 操作(prisma-crud-api)を理解していること

概要

deletedAt フィールドに削除日時を記録する論理削除(soft delete)パターン。物理的にレコードを消さずに「削除済み」として扱い、通常のクエリでは deletedAt: null を条件に付けることで自動的に除外する。

インストール

npm install prisma @prisma/client
npx prisma init

実装

スキーマ定義

// prisma/schema.prisma
datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

generator client {
  provider = "prisma-client-js"
}

model Post {
  id        Int       @id @default(autoincrement())
  title     String
  body      String
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  deletedAt DateTime? // null = 有効, 値あり = 論理削除済み
}

Prisma クライアント(シングルトン)

// lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({ log: ["query"] });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

論理削除を含む CRUD 操作

// lib/posts.ts
import { prisma } from "@/lib/prisma";

// 有効な投稿のみ取得(deletedAt: null でフィルタ)
export async function getPosts() {
  return prisma.post.findMany({
    where: { deletedAt: null },
    orderBy: { createdAt: "desc" },
  });
}

// ID 指定取得(削除済みも除外)
export async function getPost(id: number) {
  return prisma.post.findFirst({
    where: { id, deletedAt: null },
  });
}

// 論理削除: deletedAt に現在時刻をセット
export async function softDeletePost(id: number) {
  return prisma.post.update({
    where: { id },
    data: { deletedAt: new Date() },
  });
}

// 論理削除の取り消し(復元)
export async function restorePost(id: number) {
  return prisma.post.update({
    where: { id },
    data: { deletedAt: null },
  });
}

// 削除済みレコードのみ取得(管理画面用など)
export async function getDeletedPosts() {
  return prisma.post.findMany({
    where: { deletedAt: { not: null } },
    orderBy: { deletedAt: "desc" },
  });
}

// 完全削除(物理削除): 必要に応じて
export async function hardDeletePost(id: number) {
  return prisma.post.delete({ where: { id } });
}

Route Handler での使用

// app/api/posts/[id]/route.ts
import { NextResponse } from "next/server";
import { softDeletePost, getPost } from "@/lib/posts";

type Params = { params: Promise<{ id: string }> };

export async function DELETE(_req: Request, { params }: Params) {
  const { id } = await params;
  const postId = Number(id);

  const post = await getPost(postId);
  if (!post) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  await softDeletePost(postId);
  return NextResponse.json({ message: "削除しました" });
}

管理画面向け: 削除済み一覧と復元

// app/api/posts/deleted/route.ts
import { NextResponse } from "next/server";
import { getDeletedPosts } from "@/lib/posts";

export async function GET() {
  const posts = await getDeletedPosts();
  return NextResponse.json(posts);
}

// app/api/posts/[id]/restore/route.ts
import { NextResponse } from "next/server";
import { restorePost } from "@/lib/posts";

type Params = { params: Promise<{ id: string }> };

export async function POST(_req: Request, { params }: Params) {
  const { id } = await params;
  await restorePost(Number(id));
  return NextResponse.json({ message: "復元しました" });
}

ポイント

  • 論理削除は deletedAt: null で「有効」、deletedAt: DateTime で「削除済み」を表す。全クエリに where: { deletedAt: null } を付けることで削除済みレコードを除外する
  • 物理削除(prisma.post.delete)との使い分け: 監査ログが必要・復元が必要・参照整合性を保ちたい場合は論理削除。ストレージを節約したい・GDPR の完全消去が必要な場合は物理削除
  • getPost では findUnique ではなく findFirst を使う。findUniquewheredeletedAt: null を組み合わせられないため
  • Prisma の $extends を使うと softDelete をモデルメソッドとして追加できるが、このサンプルでは可読性のためシンプルな関数ラッパーで示す
  • 定期的なハードデリートが必要な場合は、deletedAt < N日前 を条件に cron ジョブで物理削除するパターンと組み合わせる

注意点

prisma-crud-api は基本的な CRUD(findMany / create / update / delete)。prisma-relation-query は include を使った関連取得。これは deletedAt による論理削除に特化し、通常クエリへの自動フィルタ適用パターンを示す。物理削除との使い分けも説明する。

関連サンプル