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:
- Visibilidade controlada — a lista principal esconde arquivos da lixeira;
- Possibilidade de restauração — mover de volta para a lista principal;
- 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
Tratar concorrência otimista e retries com EF Core e Polly em ASP.NET Core
Combina rowversion, tratamento de DbUpdateConcurrencyException e políticas Polly para retries seguros. Estratégia prática: recriar DbContext, mesclar ou…
Upload de arquivos com ASP.NET Core Minimal APIs: streaming e progresso
Guia prático para implementar upload de arquivos com ASP.NET Core Minimal APIs: streaming para evitar OOM, reporting de progresso via polling e dicas para…
Autenticação JWT em Minimal APIs: refresh tokens seguros com HttpOnly
Guia prático para implementar refresh tokens seguros com cookies HttpOnly em Minimal APIs ASP.NET Core: conceitos, passos comentados, exemplos em C# e…
Multitenancy em ASP.NET Core com EF Core: discriminator, schema e database
Comparação prática entre discriminator, schema e database para multitenancy em ASP.NET Core com EF Core. Implementação de TenantProvider, DbContext dinâmico…
EF Core: Upsert, ExecuteUpdate/ExecuteDelete e estratégias de operações em lote
Guia prático para aplicar upserts e operações em lote com EF Core: quando usar ExecuteUpdate/ExecuteDelete, MERGE/ON CONFLICT, bibliotecas de bulk e patterns…
Minimal APIs + SSE: enviar progresso de jobs para clientes Blazor passo a passo
Guia passo a passo para usar Minimal APIs + SSE e enviar progresso de jobs para clientes Blazor. Do setup ao código funcional: servidor com endpoint SSE…