
Learn how to implement reliable soft deletes in your NestJS and Prisma PostgreSQL applications using schema middleware and filters.
When building backend applications for clients, data integrity is one of the first things I focus on. Often, clients ask to delete records—user accounts, orders, or blog posts—only to realize later that they need that data back for auditing, analytics, or simple user recovery. That is where soft deletes come in.
Instead of running a destructive SQL DELETE query, a soft delete simply updates a column like deletedAt with a timestamp. The record stays in the PostgreSQL database, but your application pretends it is gone.
Today, I want to show you how I implement a clean, automated soft delete pattern using NestJS, Prisma, and PostgreSQL without writing repetitive filtering logic in every single service.
The Challenge with Soft Deletes
If you implement soft deletes manually, your codebase quickly gets messy. Every time you query a model, you have to remember to add where: { deletedAt: null }. If you forget even once, deleted records leak into your public APIs, user dashboards, and reports.
Even worse, database constraints like unique indexes can cause problems. If a user deletes their account with email test@example.com and tries to sign up again later, PostgreSQL will throw a unique constraint violation because the old record still exists in the table.
Let us solve these problems step by step.
Step 1: Updating the Prisma Schema
First, we need to update our schema.prisma file to support soft deletes. We will add a deletedAt field to our models and handle unique constraints carefully.
model User {
id String @id @default(uuid())
email String
name String?
isActive Boolean @default(true)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([email, deletedAt])
}
Notice the composite unique constraint: @@unique([email, deletedAt]). This is a neat PostgreSQL trick. Instead of enforcing uniqueness across the entire email column, it only enforces uniqueness among records where deletedAt is null. If a user deletes their account, gets a timestamp, freeing up that email address for a fresh registration.
Tagged with
Leave a comment
deletedAtStep 2: Creating a Prisma Extension for Automatic Filtering
In older versions of Prisma, we used middleware. With modern Prisma versions, client extensions are the recommended way to intercept queries. We can write an extension that automatically injects deletedAt: null into every findMany and findFirst query.
Create a prisma.service.ts file in your NestJS project:
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
// Extend Prisma Client
get extendedClient() {
return this.$extends({
query: {
user: {
async findMany({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
async findUnique({ args, query }) {
// findUnique requires unique fields, so we use findFirst instead
return query(args);
},
},
},
});
}
}
Now, whenever your application calls prisma.user.findMany(), Prisma automatically appends the condition to ignore soft-deleted rows.
Step 3: Implementing the Soft Delete Method in Your Service
When a user requests account deletion, your NestJS service should not call prisma.user.delete(). Instead, it should perform an update operation.
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Injectable()
class UsersService {
constructor(private prisma: PrismaService) {}
async softDelete(id: string) {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null },
});
if (!user) {
throw new NotFoundException('User not found');
}
return this.prisma.user.update({
where: { id },
data: { deletedAt: new Date(), isActive: false },
});
}
async restore(id: string) {
return this.prisma.user.update({
where: { id },
data: { deletedAt: null, isActive: true },
});
}
}
Handling Relations and Cascades
Soft deletion gets tricky when dealing with relational data. If you soft-delete a company, what happens to its members?
In my projects, I usually handle relational soft deletes explicitly inside a Prisma transaction. If a parent record is soft-deleted, I run an update query to mark all related child records as deleted as well. This ensures that queries filtering by parent IDs do not accidentally pull orphaned child records.
Querying Deleted Records When Needed
Sometimes, admins need to view deleted records. Because our extension automatically filters them out, how do we query them? We can bypass the extension by accessing the base PrismaClient methods directly when writing admin-specific endpoints.
async findAllWithDeleted() {
// Bypasses the extension to show everything
return this.prisma.user.findMany();
}
Conclusion
Implementing soft deletes with NestJS, Prisma, and PostgreSQL gives you the best of both worlds: clean application code with automatic safety filters, and resilient database constraints that prevent unique key collisions.
If you need help architecting robust backend APIs, building custom SaaS products, or setting up complex databases for your clients, feel free to get in touch.