Destino Dev

Blog

Soft delete no EF Core: filtros globais, auditoria e restauração

Por John Pires · 19 de março de 2026

Soft delete no EF Core: filtros globais, auditoria e restauração

O que é e por que importa

Soft delete é uma estratégia para marcar registros como removidos sem realmente apagá-los do banco de dados. Em vez de executar DELETE, a aplicação atualiza campos como IsDeleted e DeletedAt. Essa técnica preserva histórico, permite auditoria e reduz risco de perda acidental de dados.

Para quem está começando, pensar em soft delete como colocar um arquivo em uma lixeira ajuda: o arquivo não sumiu do disco, mas não aparece na lista principal. Só que, diferente da lixeira do sistema operacional, cabe ao código da aplicação decidir quando exibir ou ignorar itens marcados.

Soft delete é especialmente comum em aplicações que precisam de:

  • Auditoria de ações do usuário;
  • Restauro de dados deletados por engano;
  • Manter consistência histórica para relatórios;
  • Regras de compliance que exigem armazenamento de registros.

O Entity Framework Core (EF Core) tem suporte que facilita implementar soft delete corretamente, principalmente usando global query filters. Com eles você evita esquecer WHERE IsDeleted = FALSE em todas as queries.

Definição técnica

Tecnicamente, soft delete adiciona propriedades à entidade, por exemplo: bool IsDeleted, DateTime? DeletedAt, string DeletedBy. A lógica de remoção é transformar a operação de DELETE em UPDATE dessas colunas.

Por que importa

Remeter ao hábito comum de escrever WHERE manualmente em repositórios: em projetos maiores, checar IsDeleted manualmente vira fonte de bugs e vazamentos de dados. Filtros globais resolvem isso centralmente.

Conceitos fundamentais com analogias

Antes de partir para o código, entenda os conceitos-chave com analogias simples e diretas. Isso evita implementar soluções que aparentam funcionar, mas criam problemas laterais.

Lixeira versus exclusão permanente

Pense em soft delete como uma lixeira compartilhada na aplicação. Arquivos vão para a lixeira por padrão; políticas de retenção ou ações explicitas podem esvaziar a lixeira (hard delete). A lixeira oferece:

  1. Visibilidade controlada — a lista principal esconde arquivos da lixeira;
  2. Possibilidade de restauração — mover de volta para a lista principal;
  3. Auditoria — quem colocou o arquivo na lixeira, quando e por quê.

Filtros globais como óculos de realidade aumentada

Global query filters atuam como um óculos que modifica a visão padrão do banco. Ao colocar o óculos, você não vê itens com IsDeleted=true. Se precisar ver tudo, você tira o óculos temporariamente (IgnoreQueryFilters).

Sem os filtros globais você dependeria de lembrar onde pôr a cláusula IsDeleted=false; com o filtro, o EF Core aplica a cláusula automaticamente para todas as queries LINQ que envolvem a entidade.

Passo a passo com código comentado

Vou mostrar primeiro o jeito errado (mas comum) e depois o jeito certo com filtros globais e auditoria. O objetivo é ensinar hábitos: identificar o anti-padrão e aplicar a correção.

Jeito errado: filtrar na camada de repositório manualmente

Explicação do que faz: este exemplo mostra um repositório que aplica IsDeleted manualmente nas consultas. Funciona, mas é frágil: qualquer esqueço em outro repositório ou serviço expõe dados deletados.

// Repositório errado: filtra manualmente em cada método
public class ProdutoRepositoryBad
{
    private readonly AppDbContext _db;
    public ProdutoRepositoryBad(AppDbContext db) => _db = db;

    public IQueryable<Produto> GetAll()
    {
        // Aqui o desenvolvedor lembra de filtrar
        return _db.Produtos.Where(p => !p.IsDeleted);
    }

    public Produto GetById(int id)
    {
        // E aqui: se esquecer, pode retornar registro deletado
        return _db.Produtos.FirstOrDefault(p => p.Id == id && !p.IsDeleted);
    }
}

O que acontece: se outro desenvolvedor cria uma query direta via _db.Produtos sem o filtro, irá ver registros deletados. Em queries complexas com joins, o filtro precisa ser aplicado manualmente também — repetição e risco.

Jeito certo: filtro global e propriedades de auditoria

Explicação do que faz: o DbContext define um filtro global para todas as consultas da entidade Produto. Além disso, implementamos lógica que converte Remove em soft delete ao salvar as mudanças.

// Configuração do modelo: filtro global e shadow properties (simples exemplo)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Produto>().HasQueryFilter(p => !p.IsDeleted);

    // Mapear colunas de auditoria se necessário
    modelBuilder.Entity<Produto>().Property(p => p.DeletedAt);
    modelBuilder.Entity<Produto>().Property(p => p.DeletedBy);
}

public override int SaveChanges()
{
    foreach (var entry in ChangeTracker.Entries<Produto>())
    {
        if (entry.State == EntityState.Deleted)
        {
            // Converte a operação de delete para update de soft delete
            entry.State = EntityState.Modified;
            entry.CurrentValues["IsDeleted"] = true;
            entry.CurrentValues["DeletedAt"] = DateTime.UtcNow;
            entry.CurrentValues["DeletedBy"] = GetCurrentUserId();
        }
    }

    return base.SaveChanges();
}

O que acontece: todas as queries respeitam o filtro sem escrever WHERE manual. Remoções são interceptadas e gravam informações de auditoria. Restaurar um item exige limpar IsDeleted e DeletedAt.

Erros comuns e armadilhas

Principais armadilhas que vejo em code review:

  • Usar soft delete mas esquecer de aplicar índice em IsDeleted — consultas podem ficar lentas se a tabela for grande.
  • Interceptor de SaveChanges que altera entradas sem checar corretamente o EntityState — pode mascarar updates válidos.
  • Confundir detached entities ao tentar restaurar — se a entidade não estiver sendo rastreada, é necessário Attach antes de alterar campos.
  • Assumir que IgnoreQueryFilters volta para o estado anterior automaticamente em contextos reutilizados — tenha cuidado com scopes do DI.
Tip: crie métodos explícitos no repositório como SoftDelete(id) e Restore(id). Isso deixa a intenção clara e facilita testes.

Exemplo prático completo

Vamos construir um exemplo mínimo funcional com: entidade Produto, AppDbContext com filtro global, override de SaveChanges para soft delete, endpoints em uma minimal API para deletar e restaurar. Tudo voltado para iniciantes com explicações passo a passo.

Contexto do projeto

Estrutura de arquivos simplificada:

  • Models/Produto.cs
  • Data/AppDbContext.cs
  • Program.cs (minimal API)

Entity Produto:

// Model: representa um produto simples com soft delete e auditoria
public class Produto
{
    public int Id { get; set; }
    public string Nome { get; set; }
    public decimal Preco { get; set; }

    // Campos para soft delete e auditoria
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public string DeletedBy { get; set; }
}

O que acontece: Produto tem as propriedades necessárias para marcar exclusões e armazenar quando e quem removeu.

AppDbContext com filtro global e interceptação

Implementação do DbContext. Atenção: substitua GetCurrentUserId por lógica real em produção (por exemplo, a partir do HttpContext).

// AppDbContext com filtro global e tratamento de soft delete no SaveChanges
public class AppDbContext : DbContext
{
    public DbSet<Produto> Produtos { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Produto>().HasQueryFilter(p => !p.IsDeleted);

        // Índice em IsDeleted para melhorar performance em tabelas grandes
        modelBuilder.Entity<Produto>().HasIndex(p => p.IsDeleted);
    }

    private string GetCurrentUserId()
    {
        // Exemplo simplificado; injetar um serviço real no DbContext em produção
        return "system";
    }

    public override int SaveChanges()
    {
        SoftDeleteIntercept();
        return base.SaveChanges();
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        SoftDeleteIntercept();
        return base.SaveChangesAsync(cancellationToken);
    }

    private void SoftDeleteIntercept()
    {
        foreach (var entry in ChangeTracker.Entries<Produto>())
        {
            if (entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                entry.CurrentValues["IsDeleted"] = true;
                entry.CurrentValues["DeletedAt"] = DateTime.UtcNow;
                entry.CurrentValues["DeletedBy"] = GetCurrentUserId();
            }
        }
    }
}

O que acontece: antes de persistir, verificamos entradas marcadas como Deleted e as transformamos em Modified, aplicando os valores de auditoria. O filtro global impede que consultas comuns vejam as linhas marcadas.

Minimal API: endpoints para soft delete e restauração

Endpoints demonstram uso prático: deletar faz soft delete; restaurar usa IgnoreQueryFilters para localizar o registro marcado e reverte as colunas.

// Trecho do Program.cs com endpoints mínimos
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase("db"));
var app = builder.Build();

app.MapDelete("/produtos/{id}", async (int id, AppDbContext db) =>
{
    var p = await db.Produtos.FindAsync(id);
    if (p == null) return Results.NotFound();

    db.Produtos.Remove(p); // será convertido em soft delete pelo SaveChanges
    await db.SaveChangesAsync();
    return Results.NoContent();
});

app.MapPost("/produtos/{id}/restore", async (int id, AppDbContext db) =>
{
    // Ignora filtros para encontrar o item mesmo que esteja marcado como deletado
    var p = await db.Produtos.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id);
    if (p == null) return Results.NotFound();

    p.IsDeleted = false;
    p.DeletedAt = null;
    p.DeletedBy = null;

    await db.SaveChangesAsync();
    return Results.Ok(p);
});

app.Run();

O que acontece: a remoção passa pelo interceptor de SaveChanges que grava IsDeleted; para restaurar usamos IgnoreQueryFilters para encontrar o registro e limpar os campos de auditoria.

Próximos passos para aprofundar

Implementar soft delete é só o começo. Há detalhes importantes que afetam segurança, performance e manutenção. Abaixo, áreas para evoluir após adotar a solução básica.

Testes e migrações

Adicione testes automatizados cobrindo comportamento de: listagem padrão, consulta explícita com IgnoreQueryFilters, restauração e hard delete por políticas. Garanta que as migrations adicionem colunas IsDeleted, DeletedAt e DeletedBy com tipos e índices corretos.

Performance, índices e manutenção

Para tabelas grandes, indexar IsDeleted e outros campos usados em filtros é essencial. Avalie armazenamento de historização separado (audit trail) quando o volume de dados for muito alto. Também defina uma política clara para hard deletes (por exemplo, purge após período X) e um job que realize purges em lote.

Tip: evite usar strings para DeletedBy se você puder referenciar o id do usuário (ForeignKey) — isso melhora integridade referencial e consulta. Porém, dependendo do requisito de auditoria, manter um snapshot do nome do usuário pode ser desejável.

Conclusão prática

Soft delete com EF Core resolve um problema recorrente: preservar dados sem expô-los na UX. O caminho certo usa filtros globais, interceptação de SaveChanges para transformar deletes em updates e endpoints explícitos para restaurar registros. O jeito errado é confiar em filtros manuais espalhados pelo código — isso gera bugs difíceis de rastrear.

Checklist rápido para revisão de código:

  • Existe IsDeleted na entidade?
  • Existe HasQueryFilter no DbContext?
  • SaveChanges intercepta deletes para gravar DeletedAt/DeletedBy?
  • Há endpoints ou métodos de repositório explícitos para restaurar registros?
  • Índices e migrações foram considerados?

Implementar corretamente evita vazamentos de dados, facilita auditoria e reduz o custo de recuperar de erros humanos. Comece simples, aplique filtros globais, escreva testes e depois otimize índices e políticas de purge.

O próximo passo é aplicar essa base em seu domínio: adicione soft delete para entidades que realmente precisam de restauração e crie jobs de manutenção para hard deletes quando apropriado.

Assuntos: C#, entity-framework-core, aspnet-core, soft-delete, ef-core, audit

Leia também