Skip to main content

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:
user.entity.ts
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:
users.controller.ts
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:
DELETE /users/1
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:
GET /users
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:
PATCH /users/1/recover
Behavior:
  • Sets deletedAt to null
  • Record becomes visible in normal queries again

Configure Recovery Response

Control whether to return the recovered entity:
users.controller.ts
@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:
main.ts
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

company.entity.ts
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

companies.controller.ts
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

companies.service.ts
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:
company.entity.ts
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:
users.controller.ts
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);
  }
}
users.service.ts
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

1

Use DeleteDateColumn

Always use TypeORM’s @DeleteDateColumn() decorator for consistency.
2

Index deletedAt

Add an index to deletedAt for better query performance.
3

Audit Trail

Combine with @CreateDateColumn() and @UpdateDateColumn() for complete audit trails.
4

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:
  1. Entity has @DeleteDateColumn() decorator
  2. softDelete: true is set in query options
  3. Service extends TypeOrmCrudService

Deleted Records Still Appearing

Check:
  1. includeDeleted parameter isn’t being set
  2. Query filters aren’t overriding soft delete behavior
  3. Database column exists and is nullable

Build docs developers (and LLMs) love