Overview
Soft delete allows you to mark records as deleted without permanently removing them from the database. This is useful for:
- Maintaining data history
- Implementing “trash” functionality
- Audit trails and compliance
- Recovering accidentally deleted records
Setup
Entity Configuration
Add a deletedAt column to your entity:
import { Entity, Column, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@Column()
name: string;
@DeleteDateColumn()
deletedAt?: Date;
}
The @DeleteDateColumn() decorator from TypeORM automatically handles soft delete timestamps.
Controller Configuration
Enable soft delete in your controller:
import { Controller } from '@nestjs/common';
import { Crud } from '@nestjsx/crud';
import { User } from './user.entity';
import { UsersService } from './users.service';
@Crud({
model: { type: User },
query: {
softDelete: true,
},
})
@Controller('users')
export class UsersController {
constructor(public service: UsersService) {}
}
How It Works
When soft delete is enabled:
Delete Operation
Request:
Behavior:
- Sets
deletedAt to current timestamp
- Record remains in database
- Record is excluded from normal queries
Query Behavior
By default, soft-deleted records are excluded:
Request:
Result:
- Returns only records where
deletedAt IS NULL
- Soft-deleted records are hidden
Including Deleted Records
Use the includeDeleted query parameter:
Request:
GET /users?includeDeleted=1
Result:
- Returns all records, including soft-deleted ones
- Useful for showing “trash” views
Recovery Endpoint
The recoverOne endpoint restores soft-deleted records:
Request:
Behavior:
- Sets
deletedAt to null
- Record becomes visible in normal queries again
Control whether to return the recovered entity:
@Crud({
model: { type: User },
query: {
softDelete: true,
},
routes: {
recoverOneBase: {
returnRecovered: true, // Return the recovered entity
},
},
})
@Controller('users')
export class UsersController {
constructor(public service: UsersService) {}
}
Global Configuration
Enable soft delete globally for all controllers:
import { CrudConfigService } from '@nestjsx/crud';
CrudConfigService.load({
query: {
softDelete: true,
},
routes: {
recoverOneBase: {
returnRecovered: true,
},
},
});
Ensure all entities have a deletedAt column before enabling soft delete globally.
Complete Example
Here’s a full implementation:
Entity
import {
Entity,
Column,
PrimaryGeneratedColumn,
DeleteDateColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('companies')
export class Company {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
domain: string;
@Column({ type: 'text', nullable: true })
description?: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@DeleteDateColumn()
deletedAt?: Date;
}
Controller
import { Controller } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Crud } from '@nestjsx/crud';
import { Company } from './company.entity';
import { CompaniesService } from './companies.service';
@Crud({
model: { type: Company },
query: {
softDelete: true,
alwaysPaginate: false,
join: {
users: {
eager: true,
},
},
},
routes: {
deleteOneBase: {
returnDeleted: false, // Don't return entity after delete
},
recoverOneBase: {
returnRecovered: true, // Return entity after recovery
},
},
})
@ApiTags('companies')
@Controller('companies')
export class CompaniesController {
constructor(public service: CompaniesService) {}
}
Service
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmCrudService } from '@nestjsx/crud-typeorm';
import { Company } from './company.entity';
@Injectable()
export class CompaniesService extends TypeOrmCrudService<Company> {
constructor(@InjectRepository(Company) repo) {
super(repo);
}
}
Usage Examples
Soft Delete a Company
curl -X DELETE http://localhost:3000/companies/1
List Active Companies
curl http://localhost:3000/companies
Returns only companies where deletedAt IS NULL.
List All Companies (Including Deleted)
curl http://localhost:3000/companies?includeDeleted=1
Returns all companies, including soft-deleted ones.
Filter Deleted Companies Only
curl "http://localhost:3000/companies?includeDeleted=1&filter=deletedAt||$notnull"
Recover a Company
curl -X PATCH http://localhost:3000/companies/1/recover
With Relationships
Soft delete works with relationships:
@Crud({
model: { type: User },
query: {
softDelete: true,
join: {
company: {
eager: true,
},
projects: {},
},
},
})
@Controller('users')
export class UsersController {
constructor(public service: UsersService) {}
}
Related entities can also be soft-deleted. Configure each entity independently.
Cascade Soft Delete
To soft delete related entities, configure cascades in your entity:
import { Entity, OneToMany } from 'typeorm';
import { User } from '../users/user.entity';
@Entity('companies')
export class Company {
// ... other columns
@OneToMany(() => User, user => user.company, {
cascade: ['soft-remove'],
})
users: User[];
}
Disabling Specific Routes
You can disable the recover route if not needed:
@Crud({
model: { type: User },
query: {
softDelete: true,
},
routes: {
exclude: ['recoverOneBase'],
},
})
@Controller('users')
export class UsersController {
constructor(public service: UsersService) {}
}
Swagger Documentation
When soft delete is enabled, Swagger automatically documents:
includeDeleted query parameter on getMany and getOne
PATCH /:id/recover endpoint for recovery
deletedAt field in entity schemas
Hard Delete
To permanently delete records, implement a custom method:
import { Controller, Delete, Param } from '@nestjs/common';
import { Crud, Override } from '@nestjsx/crud';
@Crud({
model: { type: User },
query: {
softDelete: true,
},
})
@Controller('users')
export class UsersController {
constructor(public service: UsersService) {}
@Delete(':id/permanent')
async hardDelete(@Param('id') id: number) {
return this.service.hardDelete(id);
}
}
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmCrudService } from '@nestjsx/crud-typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersService extends TypeOrmCrudService<User> {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>
) {
super(userRepo);
}
async hardDelete(id: number) {
return this.userRepo.delete(id);
}
}
Best Practices
Use DeleteDateColumn
Always use TypeORM’s @DeleteDateColumn() decorator for consistency.
Index deletedAt
Add an index to deletedAt for better query performance.
Audit Trail
Combine with @CreateDateColumn() and @UpdateDateColumn() for complete audit trails.
Cleanup Strategy
Implement a cleanup job to hard delete old soft-deleted records if needed.
Database Migration
Add soft delete to existing tables:
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddSoftDeleteToUsers1234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
'users',
new TableColumn({
name: 'deletedAt',
type: 'timestamp',
isNullable: true,
default: null,
})
);
// Add index for performance
await queryRunner.query(
'CREATE INDEX "IDX_users_deletedAt" ON "users" ("deletedAt")'
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_users_deletedAt"');
await queryRunner.dropColumn('users', 'deletedAt');
}
}
Troubleshooting
Records Not Being Soft Deleted
Ensure:
- Entity has
@DeleteDateColumn() decorator
softDelete: true is set in query options
- Service extends
TypeOrmCrudService
Deleted Records Still Appearing
Check:
includeDeleted parameter isn’t being set
- Query filters aren’t overriding soft delete behavior
- Database column exists and is nullable