Voltar para Artigos
Banco de Dados8 min de leitura

Nunca Apague Nada: Soft Delete, Auditoria e LGPD

Soft Delete com @DeleteDateColumn no TypeORM, subscriber para auditoria de deleções, unique constraint em colunas com soft delete, withDeleted para histórico, e como a LGPD exige deleção física que conflita com o soft delete.

12 de agosto de 2026

Um DELETE FROM users WHERE id = '...' parece inocente, mas tem efeitos colaterais graves: registros relacionados ficam órfãos, relatórios históricos perdem dados, e o cliente que deletou a conta por engano perde tudo permanentemente. O Soft Delete — registrar uma data de exclusão em vez de apagar fisicamente — mantém o histórico íntegro enquanto o registro se torna invisível nas queries normais.

Implementação com @DeleteDateColumn

User.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  DeleteDateColumn,
} from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  // Unique precisa levar deletedAt em conta:
  // dois usuários não podem ter o mesmo email ATIVO,
  // mas um deletado pode ter o mesmo email de um ativo
  @Column({ unique: true })
  email: string;

  @Column()
  passwordHash: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  // @DeleteDateColumn: o TypeORM sabe que esta entidade usa Soft Delete
  // Automaticamente injeta WHERE deletedAt IS NULL em TODAS as queries
  @DeleteDateColumn({ nullable: true })
  deletedAt: Date | null;
}

Repositório: Soft Delete, Restore e withDeleted

O @DeleteDateColumn transforma o comportamento de todos os métodos de busca do TypeORM: findOne, find, findBy, count — todos injetam automaticamente WHERE deleted_at IS NULL. Você nunca precisa lembrar de filtrar manualmente. O withDeleted: true é o opt-in explícito para quando você precisa ver registros deletados, como em paineis de auditoria ou relatórios históricos.

TypeORMUsersRepository.ts
async delete(id: string): Promise<void> {
  // softDelete: faz UPDATE SET deletedAt = NOW() em vez de DELETE
  // O TypeORM injeta automaticamente WHERE deletedAt IS NULL
  await this.repo.softDelete({ id });
}

async restore(id: string): Promise<void> {
  // restore: limpa o deletedAt — o registro volta a aparecer nas queries
  await this.repo.restore({ id });
}

async findById(id: string): Promise<User | null> {
  // find/findOne: automaticamente filtra WHERE deletedAt IS NULL
  return this.repo.findOneBy({ id });
}

async findDeletedById(id: string): Promise<User | null> {
  // withDeleted: inclui registros soft-deleted na busca
  return this.repo.findOne({
    where: { id },
    withDeleted: true,
  });
}

async findAllIncludingDeleted(): Promise<Array<User>> {
  // Histórico completo para relatórios de auditoria
  return this.repo.find({ withDeleted: true });
}

async hardDelete(id: string): Promise<void> {
  // Deleção física — use APENAS quando exigido por LGPD/GDPR
  // (direito ao esquecimento do titular)
  await this.repo.delete({ id }); // DELETE real no banco
}

O Problema do Unique Constraint com Soft Delete

O @Column({ unique: true }) no email causa um problema: se um usuário é soft-deleted e tenta criar uma nova conta com o mesmo email, o banco rejeita com violação de unique — mesmo com o registro anterior marcado como deletado. A solução é usar um índice condicional no PostgreSQL:

1234_AddConditionalUniqueIndex.ts
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddConditionalUniqueIndex1234 implements MigrationInterface {
  async up(queryRunner: QueryRunner): Promise<void> {
    // Remove o unique constraint simples
    await queryRunner.query(`
      ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
    `);

    // Cria um índice único APENAS para registros não-deletados
    // Registros com deletedAt != NULL podem ter o mesmo email
    await queryRunner.query(`
      CREATE UNIQUE INDEX users_email_unique_active
      ON users (email)
      WHERE deleted_at IS NULL;
    `);
  }

  async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX IF EXISTS users_email_unique_active;`);
    await queryRunner.query(`ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);`);
  }
}

Soft Delete vs LGPD/GDPR

A LGPD (Lei Geral de Proteção de Dados) e o GDPR europeu garantem ao titular o direito ao esquecimento: quando solicitado, os dados pessoais devem ser eliminados de forma permanente. O Soft Delete conflita diretamente com isso — o dado ainda existe no banco. A solução não é remover o Soft Delete, mas implementar anonimização: substituir dados pessoais por valores genéricos enquanto mantém o registro para integridade referencial e obrigações legais de retenção.

  • Dados de negócio (pedidos, faturas, histórico financeiro) — use Soft Delete. Eles têm obrigação legal de retenção (Código Civil, Receita Federal).
  • Dados pessoais identificáveis (nome, CPF, email, endereço) — implemente anonimização: ao invés de apagar, substitua por valores genéricos ([REMOVIDO], UUID aleatório). O histórico de negócio continua íntegro, mas sem identificar a pessoa.
  • Dados sensíveis (senhas, tokens) — hard delete imediato sem exceção.
AnonymizeUserUseCase.ts (LGPD compliance)
export class AnonymizeUserUseCase {
  async execute(userId: string): Promise<void> {
    const user = await this.usersRepo.findById(userId);
    if (!user) throw new AppError('Usuário não encontrado.', 404);

    // Anonimiza dados pessoais — mantém o registro para integridade referencial
    user.name = '[CONTA ENCERRADA]';
    user.email = `removed-${randomUUID()}@encerrado.com`; // Único mas não rastreável
    user.passwordHash = 'INVALIDATED'; // Torna o hash inválido
    user.avatarUrl = null;
    user.deletedAt = new Date();

    await this.usersRepo.save(user);

    // Hard delete de dados sensíveis relacionados
    await this.refreshTokensRepo.deleteAllByUser(userId);
    await this.addressesRepo.deleteAllByUser(userId);

    // Log de auditoria: 'usuário ID X anonimizado em data Y'
    await this.auditLog.record('USER_ANONYMIZED', { userId });
  }
}

Documente sua política de retenção de dados. Para cada tipo de dado, defina: quanto tempo fica armazenado, em qual formato (completo vs anonimizado), e qual o trigger para deleção (solicitação do usuário, prazo legal, inatividade). Essa documentação é parte obrigatória da conformidade com LGPD.

Conclusão

Soft Delete com @DeleteDateColumn resolve integridade referencial, auditoria e recuperação acidental com zero esforço extra — o TypeORM injeta o filtro automaticamente em todas as queries. O índice condicional no PostgreSQL resolve o conflito com unique constraints. E a anonimização para LGPD garante compliance sem quebrar o histórico de negócio: o usuário some como entidade identificável, mas os pedidos e faturas permanecem consistentes.