How to Soft Delete Records in NestJS (TypeORM Restore, Force Delete, and Trashed Queries)
A complete walkthrough of soft deletes in NestJS with TypeORM — add a deleted_at column, intercept repo.remove(), restore and force-delete records, and query trashed rows without surprising your team.
Khatab Wedaa
Software Engineer · Nestbolt
Hard deletes feel cleaner until the day a customer support ticket lands on your desk that reads, "I accidentally deleted our entire team's project, can you bring it back?" The honest answer is usually a database backup restore, a long Slack thread, and a postmortem. Soft deletes — marking rows as deleted instead of removing them — turn that thirty-minute crisis into a one-line UPDATE.
This guide walks through implementing soft deletes properly in NestJS with TypeORM. We'll add a deleted_at column, intercept repo.remove() to convert it into a soft delete, build restore() and forceDelete() operations, and handle the surprisingly subtle question of what repository.find() should return when you have trashed rows in the table. The patterns work on TypeORM 0.3+; the same shape translates directly to Prisma's deletedAt column or Sequelize's paranoid: true.
What you'll build
By the end of this tutorial, your NestJS app will support:
- A
deleted_attimestamp column on entities you opt in post.softDelete(),post.restore(), andpost.forceDelete()mixin methods- A subscriber that converts
repository.remove(post)into a soft delete automatically — so existing code keeps working withTrashed()andonlyTrashed()query helpers for the admin "recycle bin" view- A clear, explicit policy on whether trashed rows leak into normal reads
Prerequisites
- Node.js 18 or later
- A NestJS 10+ project with TypeORM configured
- An entity you'd like to soft-delete (we'll use
Postin the examples)
1. Add the deleted_at column
The whole feature pivots on one column. Conventionally deleted_at (snake_case in the DB, deletedAt on the entity), nullable, defaulting to NULL. A non-null value means "this row is in the trash."
// src/posts/post.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
} from "typeorm";
@Entity("posts")
export class Post {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column()
title!: string;
@Column({ type: "text" })
body!: string;
@CreateDateColumn({ name: "created_at" })
createdAt!: Date;
@Column({
name: "deleted_at",
type: "timestamp",
nullable: true,
default: null,
})
deletedAt!: Date | null;
}Two things to note:
type: "timestamp", notboolean. A timestamp tells you when the row was deleted, which is useful for "auto-purge after 30 days" jobs and for showing trash items by recency in the admin UI. A boolean throws all that information away.- No default
CURRENT_TIMESTAMP. Live rows haveNULL. The column is set explicitly when soft-deleting.
If you want a global IS NULL filter on every query, add an index on (deleted_at) — the index speeds up the implicit filter and the trash-only queries equally.
2. The soft-delete service
The core operations are three lines each:
// src/soft-delete/soft-delete.service.ts
@Injectable()
export class SoftDeleteService {
constructor(private readonly dataSource: DataSource) {}
async softDelete<T>(EntityClass: EntityTarget<T>, id: string) {
const repo = this.dataSource.getRepository(EntityClass);
await repo.update(id as any, { deletedAt: new Date() } as any);
}
async restore<T>(EntityClass: EntityTarget<T>, id: string) {
const repo = this.dataSource.getRepository(EntityClass);
await repo.update(id as any, { deletedAt: null } as any);
}
async forceDelete<T>(EntityClass: EntityTarget<T>, id: string) {
const repo = this.dataSource.getRepository(EntityClass);
await repo
.createQueryBuilder()
.delete()
.where("id = :id", { id })
.execute();
}
}That's the entire write side. Three operations, no surprises:
softDeletewrites the timestamp.restoreclears it.forceDeleteruns an actualDELETE— the only path that physically removes the row.
A controller using it directly:
@Delete(":id")
softDelete(@Param("id") id: string) {
return this.softDelete.softDelete(Post, id);
}
@Post(":id/restore")
restore(@Param("id") id: string) {
return this.softDelete.restore(Post, id);
}
@Delete(":id/force")
forceDelete(@Param("id") id: string) {
return this.softDelete.forceDelete(Post, id);
}3. The mixin pattern (optional but nice)
If you'd rather call post.softDelete() than softDeleteService.softDelete(Post, post.id), mixin the methods onto the entity:
// src/soft-delete/soft-delete.mixin.ts
type Constructor<T = unknown> = new (...args: any[]) => T;
export function SoftDeletableMixin<TBase extends Constructor>(Base: TBase) {
class SoftDeletable extends Base {
declare id: string;
declare deletedAt: Date | null;
async softDelete(this: SoftDeletable & { id: string }) {
this.deletedAt = new Date();
await getRepository(this.constructor).save(this);
}
async restore(this: SoftDeletable & { id: string }) {
this.deletedAt = null;
await getRepository(this.constructor).save(this);
}
async forceDelete(this: SoftDeletable & { id: string }) {
await getRepository(this.constructor).remove(this as any);
}
isTrashed() {
return this.deletedAt !== null;
}
getDeletedAt() {
return this.deletedAt;
}
}
return SoftDeletable;
}Then the entity becomes:
@Entity("posts")
export class Post extends SoftDeletableMixin(class { id!: string }) {
@PrimaryGeneratedColumn("uuid")
declare id: string;
@Column()
title!: string;
@Column({ name: "deleted_at", type: "timestamp", nullable: true, default: null })
deletedAt!: Date | null;
}And calls become readable:
const post = await postRepo.findOneByOrFail({ id });
await post.softDelete();
// Later...
await post.restore();
// Or for good...
await post.forceDelete();4. The TypeORM remove() trap
Here's where most teams get bit. Code that already exists in your codebase calls repo.remove(post). After you add soft deletes, every one of those calls quietly continues to hard-delete the row. The new softDelete() API only helps for code you change.
The fix is a TypeORM subscriber that intercepts beforeRemove for entities marked soft-deletable:
// src/soft-delete/soft-delete.subscriber.ts
import {
EventSubscriber,
EntitySubscriberInterface,
RemoveEvent,
DataSource,
} from "typeorm";
const SOFT_DELETABLE = Symbol("soft-deletable");
export const SoftDeletable = (): ClassDecorator => (target) => {
Reflect.defineMetadata(SOFT_DELETABLE, true, target);
};
@EventSubscriber()
export class SoftDeleteSubscriber implements EntitySubscriberInterface {
constructor(dataSource: DataSource) {
dataSource.subscribers.push(this);
}
async beforeRemove(event: RemoveEvent<unknown>) {
if (!Reflect.getMetadata(SOFT_DELETABLE, event.metadata.target)) return;
const entity = event.entity as { id: string; deletedAt: Date | null };
if (!entity?.id) return;
await event.manager
.createQueryBuilder()
.update(event.metadata.target)
.set({ deletedAt: new Date() })
.where("id = :id", { id: entity.id })
.execute();
// prevent the actual DELETE from running
Object.assign(entity, { __abortRemove: true });
throw new Error("__SOFT_DELETED__");
}
}Then opt in on the entity:
@SoftDeletable()
@Entity("posts")
export class Post { /* ... */ }A pragmatic warning: TypeORM doesn't have a clean "abort this operation" hook in subscribers, so the throw above is a workaround. The cleaner version uses a transactional callback that swallows the soft-delete sentinel error. The full implementation is fiddly enough that this is the part you most want to lift from a battle-tested package rather than write yourself.
5. Querying trashed rows
The decision that trips teams up: should repo.find() exclude trashed rows automatically?
There are two camps:
- Implicit filter (Laravel-style):
repo.find()returns only live rows. To see trashed, you callwithTrashed(). - Explicit (no filter):
repo.find()returns everything. You addWHERE deleted_at IS NULLmanually.
Both are defensible. The implicit version is more ergonomic but bites you the day a developer writes a manual SQL query and forgets the filter. The explicit version is more verbose but every query you read tells you exactly what's included.
Most NestJS apps end up doing the explicit version — TypeORM's subscriber API makes the implicit one harder to do safely than it looks. Two helpers cover the explicit case:
// src/soft-delete/soft-delete.service.ts (additions)
withTrashed<T>(EntityClass: EntityTarget<T>, alias: string) {
return this.dataSource.getRepository(EntityClass).createQueryBuilder(alias);
}
onlyTrashed<T>(EntityClass: EntityTarget<T>, alias: string) {
return this.dataSource
.getRepository(EntityClass)
.createQueryBuilder(alias)
.where(`${alias}.deleted_at IS NOT NULL`);
}Then in your code:
// All posts, including trash
const all = await this.softDelete.withTrashed(Post, "post").getMany();
// Only the trash, ordered by deletion time
const trashed = await this.softDelete
.onlyTrashed(Post, "post")
.orderBy("post.deleted_at", "DESC")
.getMany();
// Live posts (the explicit policy — every read says what it includes)
const live = await this.postRepo
.createQueryBuilder("post")
.where("post.deleted_at IS NULL")
.getMany();For the "live posts" path you can also wrap a custom repository method findActive() to avoid repeating the IS NULL clause everywhere.
6. Common pitfalls
| Pitfall | What goes wrong | Fix |
|---|---|---|
Boolean is_deleted instead of timestamp | No "when was it deleted" data, no auto-purge job | Use deleted_at timestamp |
repo.remove() still hard-deletes existing code | Old call sites silently destroy data | Add the subscriber, opt in with @SoftDeletable() |
| Implicit filter applied inconsistently | Some queries see trash, others don't | Pick one policy and document it |
| Foreign keys cascade-delete trashed parents | Restoring a parent can't restore children | Soft-delete children too, restore them in a transaction |
Unique indexes ignore deleted_at | Soft-deleted email blocks new signups | Use a partial unique index: WHERE deleted_at IS NULL |
| Cron job that purges trash older than X | Surprises the customer who wanted to restore | Make the retention window long (90+ days) and configurable |
Mass repo.delete(query) calls | Bulk operations bypass the subscriber | Use repo.createQueryBuilder().update().set({ deletedAt: new Date() }) |
The unique-index trap is the one that surprises everyone. If users.email has a UNIQUE constraint and you soft-delete alice@example.com, the row is still in the table. When alice tries to sign up again, the insert fails on the unique constraint. The fix is a partial unique index — Postgres supports it directly:
CREATE UNIQUE INDEX users_email_active_idx
ON users (email)
WHERE deleted_at IS NULL;MySQL doesn't have partial indexes, so you either compute a separate email_canonical column that's set to a unique sentinel on soft-delete, or you handle the conflict at the application layer.
Going further: @nestbolt/soft-delete
Soft deletes are deceptively detailed. The mixin, the subscriber, the metadata registry, the service, and the query helpers are all small individually but they need to play together correctly — and the repo.remove() interception in step 4 is the kind of code you'd rather not maintain yourself.
@nestbolt/soft-delete is the cleaned-up version. It ships:
@SoftDeletable()decorator andSoftDeletableMixin(Base)for entity-instance methodsSoftDeleteService.softDelete(Class, id),restore, andforceDeleteSoftDeleteService.withTrashed(Class, alias)andonlyTrashed(Class, alias)query buildersSoftDeleteSubscriberthat best-effort interceptsrepo.remove()and converts it to a soft delete (with the caveats from step 4 documented)- Lifecycle events (
soft-delete.deleted,soft-delete.restored,soft-delete.force-deleted) you can listen to for cache invalidation or audit logs
Setup is one line — SoftDeleteModule.forRoot() in your AppModule, plus @SoftDeletable() on the entities you want to opt in. The full API is in the soft-delete quick-start.
Wrapping up
Soft deletes are a small feature with disproportionate value. Adding them takes a column, a service, and a subscriber. The peace of mind they give you — the knowledge that no support ticket about "we deleted the wrong thing" is a real emergency anymore — is worth more than the implementation effort. The traps are all subtle (the unique-index one especially), but once you know them, they're one-time setup costs.
If this saved you from rebuilding the same soft-delete layer for the third project, star the repo. If you'd like a follow-up on cascading soft-deletes across relations, open an issue on GitHub.
Written by Khatab Wedaa
Software Engineer · Nestbolt
Building open-source NestJS packages — authentication, permissions, audit logs, media uploads, and the patterns every backend ends up rebuilding.