Soft Delete com ASP.NET e EF

Soft delete com ASP.NET Core e Entity Framework Core aparece com frequência nas perguntas que recebo, e existem várias formas de resolver esse problema. Neste artigo, você vai ver uma forma simples e fácil de implementar a técnica usando os filtros globais de consulta do EF Core, em uma API construída com o .NET Core 3.1.

A ideia é manter o código dos controladores limpo, sem espalhar cláusulas Where pelo projeto. Além disso, você vai aprender a desligar o filtro quando precisar listar também os registros marcados como excluídos.

O que é soft delete

Em determinados cenários, não fazemos a exclusão permanente de um registro no banco de dados. Em vez disso, usamos uma flag para indicar se o item está ativo ou inativo.

Essa prática se chama soft delete, ou exclusão lógica. Na prática, criamos uma propriedade booleana na entidade que determina se o objeto foi "excluído" ou não.

O oposto é o hard delete, ou exclusão física. Nesse caso, o comando DELETE remove a linha da tabela e a informação deixa de existir.

Por que usar soft delete

A documentação oficial do EF Core cita dois motivos principais para adotar o soft delete. Primeiro, ele permite restaurar um registro excluído por engano.

Segundo, ele preserva uma trilha de auditoria, já que as linhas excluídas continuam acessíveis quando necessário. Por isso, sistemas financeiros, cadastros de clientes e backoffices costumam adotar essa abordagem.

Também existe um ganho de integridade. Quando outros registros apontam para o item excluído, como pedidos que referenciam um produto, a exclusão lógica evita quebrar o histórico.

O problema que os filtros globais resolvem

Sem nenhum recurso extra, toda consulta precisaria lembrar de filtrar os itens excluídos. Ou seja, cada Get, cada relatório e cada busca teria um Where(x => !x.Deleted) escrito à mão.

Esse tipo de repetição é frágil, porque basta um desenvolvedor esquecer o filtro para dados excluídos voltarem à tela. É exatamente aqui que entram os filtros globais de consulta do EF Core.

Criando a API

Para executar esta demonstração, você precisa do .NET Core 3.1 instalado na sua máquina. Você pode seguir o guia oficial de instalação do .NET para isso.

Caso este seja seu primeiro contato com APIs, recomendo a formação Backend Developer .NET, onde você começa de graça e aprende os fundamentos do ASP.NET Core. Também vale ler o artigo criando uma API do zero com .NET Core 3.1 e EF Core.

Com tudo instalado, navegue até uma pasta segura pelo seu terminal favorito e execute o seguinte comando:

dotnet new webapi -o backoffice

Você pode trocar o nome backoffice se quiser, mas usei esse nome no repositório desta demonstração. Em seguida, abra a pasta recém criada no Visual Studio Code.

Antes de implementar o soft delete, precisamos de uma API funcionando com o EF Core configurado.

Como vamos usar um banco de dados em memória, também precisamos do provedor InMemory do EF Core. Então, adicione o pacote na versão compatível com o 3.1:

dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 3.1.0

Criando o modelo com a flag de soft delete

Independentemente de você trabalhar com modelos ou entidades, a única coisa que precisamos aqui é deixar explícito se o item está ou não ativo. Se você tiver dúvidas sobre Model, Entity e outros pontos de orientação a objetos, faça o curso Modelando Domínios Ricos.

Neste caso, a forma que escolhi foi criar um booleano chamado Deleted no modelo. Pode ser que, no seu cenário, isso não faça sentido.

De qualquer forma, trata-se apenas de um exemplo. O resultado do arquivo Models/Product.cs fica assim:

namespace Backoffice.Models
{
    public class Product
    {
        public int Id { get; set; }
        public string Title { get; set; }

        // Flag usada pelo soft delete
        public bool Deleted { get; set; }
    }
}

Algumas equipes preferem uma data, como DeletedAt, em vez de um booleano. Dessa forma, além de saber que o item foi excluído, você sabe quando isso aconteceu.

Data context

Para trabalhar com o EF Core, precisamos de uma classe central, o data context, e nela definimos quais modelos serão mapeados. Neste exemplo, vou usar um banco de dados em memória, já que a origem dos dados não faz diferença para a técnica.

Assim, o arquivo Data/DataContext.cs fica com o seguinte conteúdo:

using Backoffice.Models;
using Microsoft.EntityFrameworkCore;

namespace Backoffice.Data
{
    public class DataContext : DbContext
    {
        public DataContext(DbContextOptions<DataContext> options)
            : base(options)
        {
        }

        public DbSet<Product> Products { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Product>().Property(x => x.Id);
            modelBuilder.Entity<Product>().Property(x => x.Title);
        }
    }
}

Se quiser se aprofundar nesse estilo de configuração, o artigo sobre Fluent API mostra outras opções de mapeamento.

Registrando o contexto no Startup

Para finalizar esta parte, registre o DataContext com o AddDbContext no método ConfigureServices do arquivo Startup.cs. Repare que usamos UseInMemoryDatabase para criar o banco em memória.

using Backoffice.Data;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Backoffice
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();

            // Registra o contexto usando um banco em memória
            services.AddDbContext<DataContext>(opt => opt.UseInMemoryDatabase("Database"));
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();
            app.UseRouting();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

Pronto, agora o ASP.NET Core já consegue injetar o contexto nos controladores.

Controllers

Para este exemplo, preparei um controlador bem simples, com dois métodos. O primeiro cria um novo produto e o segundo lista todos os produtos.

using System.Collections.Generic;
using Backoffice.Data;
using Backoffice.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Backoffice.Controllers
{
    [ApiController]
    [Route("products")]
    public class HomeController : ControllerBase
    {
        [HttpGet]
        public IEnumerable<Product> Get([FromServices] DataContext context)
        {
            return context.Products.AsNoTracking();
        }

        [HttpPost]
        public Product Post([FromServices] DataContext context, [FromBody] Product product)
        {
            context.Products.Add(product);
            context.SaveChanges();
            return product;
        }
    }
}

Note que o método Get não tem nenhum filtro. Além disso, ele usa AsNoTracking, porque a listagem não precisa que o EF Core acompanhe as alterações dos objetos.

Esse detalhe é proposital. O objetivo é mostrar que o soft delete vai funcionar sem tocar neste controlador, apenas com uma configuração no modelo.

Repare também que o controlador recebe o DataContext pelo atributo [FromServices]. Assim, a injeção de dependência acontece direto no parâmetro da action, sem um construtor.

Testando a API

Abra um terminal no VS Code e execute o comando dotnet watch run. Com isso, a API vai rodar nas portas 5000 e 5001.

Em seguida, abra o Postman e execute os seguintes testes:

  • POST https://localhost:5001/products com o corpo {"title": "Product 1", "deleted": false}
  • POST https://localhost:5001/products com o corpo {"title": "Product 2", "deleted": true}
  • POST https://localhost:5001/products com o corpo {"title": "Product 3", "deleted": false}
  • GET https://localhost:5001/products, que deve retornar os três produtos cadastrados

Por enquanto, o segundo produto aparece na listagem, mesmo marcado como excluído. Esse é o comportamento que vamos corrigir agora.

Em um cenário real, isso seria um defeito grave, porque o usuário veria itens que acredita ter apagado. Por isso, o soft delete só faz sentido quando a leitura respeita a flag em todos os pontos da aplicação.

Uma alternativa ingênua seria acrescentar o Where dentro do Get. Contudo, essa solução resolve apenas este endpoint e deixa todos os outros expostos ao mesmo erro.

Soft delete com filtros globais

Agora chegou a parte mais interessante. Você deve ter notado que, embora o produto tenha sido cadastrado com Deleted igual a true, ele continua sendo listado no GET.

Isso acontece porque não usamos um filtro WHERE na consulta de produtos do HomeController. Na verdade, não queremos esse filtro lá, queremos que ele valha para a aplicação inteira.

Como o HasQueryFilter funciona

O EF Core oferece um recurso chamado filtro global de consulta, configurado pelo método HasQueryFilter. Ele anexa um predicado a um tipo de entidade, e esse predicado entra em toda consulta feita sobre ela.

Em outras palavras, a documentação descreve o filtro como um operador Where adicional, que o EF Core acrescenta sempre que alguém consulta a entidade. O recurso existe desde o EF Core 2.0 e está disponível no EF Core 3.1 que usamos aqui.

Aplicando o soft delete no DataContext

A configuração do soft delete fica no método OnModelCreating do DataContext. Basta acrescentar uma linha:

using Backoffice.Models;
using Microsoft.EntityFrameworkCore;

namespace Backoffice.Data
{
    public class DataContext : DbContext
    {
        public DataContext(DbContextOptions<DataContext> options)
            : base(options)
        {
        }

        public DbSet<Product> Products { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Product>().Property(x => x.Id);
            modelBuilder.Entity<Product>().Property(x => x.Title);

            // Filtro global: oculta os produtos marcados como excluídos
            modelBuilder.Entity<Product>().HasQueryFilter(p => !p.Deleted);
        }
    }
}

Como a aplicação usa dados em memória, essa alteração reinicia a API e apaga os produtos cadastrados. Então, repita os testes da seção anterior.

Agora, o GET retorna apenas os produtos que não foram excluídos. Isso acontece mesmo sem nenhuma mudança no controlador, porque o soft delete passou a ser responsabilidade do modelo.

Ignorando o filtro de soft delete

Para finalizar, vamos aprender a desabilitar o filtro de forma pontual. Imagine que, em um método específico, você queira listar todos os produtos, incluindo os excluídos.

Nesse caso, usamos o método IgnoreQueryFilters. Ele faz a consulta ignorar todos os filtros globais configurados para a entidade.

[HttpGet]
[Route("all")]
public IEnumerable<Product> GetAll([FromServices] DataContext context)
{
    // Ignora o filtro global e traz também os itens excluídos
    return context.Products.IgnoreQueryFilters().AsNoTracking();
}

O código final do controlador fica assim:

using System.Collections.Generic;
using Backoffice.Data;
using Backoffice.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Backoffice.Controllers
{
    [ApiController]
    [Route("products")]
    public class HomeController : ControllerBase
    {
        [HttpGet]
        public IEnumerable<Product> Get([FromServices] DataContext context)
        {
            return context.Products.AsNoTracking();
        }

        [HttpPost]
        public Product Post([FromServices] DataContext context, [FromBody] Product product)
        {
            context.Products.Add(product);
            context.SaveChanges();
            return product;
        }

        [HttpGet]
        [Route("all")]
        public IEnumerable<Product> GetAll([FromServices] DataContext context)
        {
            return context.Products.IgnoreQueryFilters().AsNoTracking();
        }
    }
}

De volta aos testes, execute um GET em https://localhost:5001/products/all. Assim, você visualiza todos os produtos novamente, incluindo os excluídos pelo soft delete.

Um cuidado importante: rotas como essa costumam expor dados sensíveis. Por isso, em um projeto real, proteja o endpoint com autorização e libere o acesso apenas para perfis administrativos.

Automatizando o soft delete no SaveChanges

No exemplo acima, quem marca o produto como excluído é o próprio cliente da API, enviando deleted no corpo. A documentação do EF Core lembra que, nesse ponto, você precisa definir a flag manualmente.

Para uma solução mais completa, você pode sobrescrever o SaveChanges do contexto. Dessa forma, toda entidade removida com Remove passa a ser apenas atualizada, com a flag ligada.

public override int SaveChanges()
{
    ChangeTracker.DetectChanges();

    // Converte exclusões físicas em exclusões lógicas
    foreach (var entry in ChangeTracker.Entries<Product>())
    {
        if (entry.State == EntityState.Deleted)
        {
            entry.State = EntityState.Modified;
            entry.CurrentValues["Deleted"] = true;
        }
    }

    return base.SaveChanges();
}

Com isso, um endpoint DELETE pode chamar context.Products.Remove(product) normalmente. O EF Core vai gerar um UPDATE em vez de um DELETE, e o filtro global continua escondendo o item.

Se você usa os métodos assíncronos, aplique a mesma lógica no SaveChangesAsync. Caso contrário, parte das exclusões continuaria sendo física.

Cuidados ao usar soft delete no EF Core

O soft delete resolve muitos problemas, mas traz alguns pontos de atenção. Conhecê-los evita surpresas em produção.

Apenas um filtro por entidade

No EF Core 3.1, cada chamada a HasQueryFilter sobrescreve a anterior. Portanto, se você também precisar filtrar por tenant, combine as condições na mesma expressão com o operador &&.

// As duas condições ficam no mesmo filtro
modelBuilder.Entity<Product>()
    .HasQueryFilter(p => !p.Deleted && p.TenantId == _tenantId);

A consequência é que o IgnoreQueryFilters desliga as duas condições de uma vez. Filtros nomeados, que você desliga separadamente, só chegaram em versões bem mais recentes do EF Core.

Herança e índices únicos

Outra limitação: você só consegue definir o filtro no tipo raiz de uma hierarquia de herança. Ou seja, as classes derivadas herdam o filtro da classe base.

Além disso, registros excluídos continuam ocupando espaço nos índices únicos. Se um produto excluído tem o mesmo código de um novo, o banco pode recusar a inserção, então avalie índices filtrados no seu banco relacional.

Testes e o provedor em memória

O banco em memória é ótimo para demonstrações, porém ele não se comporta como um banco relacional. Por isso, valide o comportamento do soft delete também contra o banco real que você usa em produção.

Também vale lembrar que o filtro atua apenas nas consultas LINQ do EF Core. Se você escrever SQL manualmente ou usar outra ferramenta, como o Dapper, precisará repetir a condição por conta própria.

Quando não usar soft delete

Nem todo dado deve ficar guardado para sempre. Em alguns casos, a legislação de proteção de dados exige a remoção real das informações pessoais quando o titular solicita.

Nesse cenário, a exclusão lógica não basta, e você precisa de uma rotina de exclusão física ou de anonimização. Portanto, decida a estratégia de cada entidade com base nas regras do negócio, e não por padrão.

Tabelas muito volumosas também merecem atenção. Afinal, linhas excluídas continuam crescendo a base e pesando nas consultas, então um processo de arquivamento periódico pode ajudar.

Código fonte

O código completo desta demonstração está disponível no GitHub, no repositório aspnetcore-efcore-soft-delete. Você pode clonar o projeto e repetir os testes descritos aqui.

Conclusão sobre soft delete

Implementar soft delete com ASP.NET Core e EF Core é simples quando usamos os filtros globais de consulta. Com uma linha no OnModelCreating, toda a aplicação passa a ignorar os itens excluídos.

Quando precisar enxergar tudo, o IgnoreQueryFilters resolve de forma pontual. Além disso, sobrescrever o SaveChanges transforma qualquer Remove em uma exclusão lógica.

Por fim, lembre dos cuidados com navegações obrigatórias, índices únicos e SQL manual. Assim, o soft delete protege seus dados sem criar comportamentos inesperados.

FAQ

O que é soft delete?

É uma técnica que mantém o registro no banco e apenas liga uma flag indicando a exclusão. Assim, ele pode ser restaurado ou auditado depois.

Qual método do EF Core aplica o filtro global?

O método HasQueryFilter, chamado dentro do OnModelCreating para a entidade desejada.

Como listar também os registros excluídos?

Use o IgnoreQueryFilters na consulta. Ele desliga os filtros globais apenas naquela query.

O filtro global funciona com SQL escrito à mão?

Não. Ele vale para consultas LINQ do EF Core, então SQL manual ou Dapper precisam repetir a condição.

Posso ter mais de um filtro por entidade no EF Core 3.1?

Não diretamente, porque cada HasQueryFilter sobrescreve o anterior. Combine as condições com o operador && em uma única expressão.