.NET Entity Framework Core EF Core Auditoria DDD Boas Práticas Banco de Dados

EF Core Interceptors: auditoria automática, soft delete e query tagging sem tocar nas entidades

Guia completo de EF Core Interceptors: ISaveChangesInterceptor para auditoria e soft delete transparente, IDbCommandInterceptor para query tagging e.

N
Neryx Digital Architects
31 de outubro de 2025
12 min de leitura
190 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

Adicionar auditoria ao EF Core da forma errada contamina todas as entidades com campos CreatedAt, UpdatedAt, CreatedBy gerenciados manualmente em cada SaveChanges. O soft delete da forma errada espalha .Where(x => !x.IsDeleted) por toda a codebase. EF Core Interceptors resolvem esses dois problemas — e vários outros — de forma transparente, sem tocar nas entidades e sem duplicar código.

O que são Interceptors no EF Core

Interceptors são ganchos que permitem interceptar operações do EF Core antes, durante ou depois de sua execução: salvar mudanças (ISaveChangesInterceptor), executar comandos SQL (IDbCommandInterceptor), abrir conexões (IDbConnectionInterceptor), e iniciar transações (IDbTransactionInterceptor). Diferente de SaveChanges sobrescrito no DbContext, interceptors são composáveis — você pode ter vários, cada um com uma responsabilidade.

ISaveChangesInterceptor: auditoria automática

// Interfaces que as entidades auditáveis implementam
// Ficam no domínio — apenas a interface, sem lógica de preenchimento

public interface ICreatable
{
    DateTime CreatedAt { get; set; }
    string CreatedBy { get; set; }
}

public interface IModifiable
{
    DateTime? UpdatedAt { get; set; }
    string? UpdatedBy { get; set; }
}

public interface IAuditable : ICreatable, IModifiable { }

// Entidade de domínio — limpa, sem lógica de auditoria
public class Order : IAuditable
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public decimal Total { get; private set; }
    public OrderStatus Status { get; private set; }

    // Campos de auditoria presentes, mas nunca preenchidos manualmente
    public DateTime CreatedAt { get; set; }
    public string CreatedBy { get; set; } = "";
    public DateTime? UpdatedAt { get; set; }
    public string? UpdatedBy { get; set; }
}
// Infrastructure/Interceptors/AuditInterceptor.cs
public class AuditInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUser;

    public AuditInterceptor(ICurrentUserService currentUser)
        => _currentUser = currentUser;

    // Intercepta SaveChanges síncrono
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        ApplyAuditFields(eventData.Context);
        return base.SavingChanges(eventData, result);
    }

    // Intercepta SaveChangesAsync
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken ct = default)
    {
        ApplyAuditFields(eventData.Context);
        return base.SavingChangesAsync(eventData, result, ct);
    }

    private void ApplyAuditFields(DbContext? context)
    {
        if (context is null) return;

        var now = DateTime.UtcNow;
        var userId = _currentUser.UserId ?? "system";

        foreach (var entry in context.ChangeTracker.Entries())
        {
            switch (entry.State)
            {
                case EntityState.Added when entry.Entity is ICreatable creatable:
                    creatable.CreatedAt = now;
                    creatable.CreatedBy = userId;
                    // Se também é IModifiable, inicializa os campos de update
                    if (entry.Entity is IModifiable modifiable)
                    {
                        modifiable.UpdatedAt = now;
                        modifiable.UpdatedBy = userId;
                    }
                    break;

                case EntityState.Modified when entry.Entity is IModifiable modifiable:
                    modifiable.UpdatedAt = now;
                    modifiable.UpdatedBy = userId;
                    // Garante que CreatedAt/CreatedBy nunca sejam sobrescritos
                    if (entry.Entity is ICreatable)
                    {
                        entry.Property(nameof(ICreatable.CreatedAt)).IsModified = false;
                        entry.Property(nameof(ICreatable.CreatedBy)).IsModified = false;
                    }
                    break;
            }
        }
    }
}

ISaveChangesInterceptor: soft delete transparente

// Entidades com soft delete implementam esta interface
public interface ISoftDeletable
{
    bool IsDeleted { get; set; }
    DateTime? DeletedAt { get; set; }
    string? DeletedBy { get; set; }
}

// Infrastructure/Interceptors/SoftDeleteInterceptor.cs
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUser;

    public SoftDeleteInterceptor(ICurrentUserService currentUser)
        => _currentUser = currentUser;

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        ApplySoftDelete(eventData.Context);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken ct = default)
    {
        ApplySoftDelete(eventData.Context);
        return base.SavingChangesAsync(eventData, result, ct);
    }

    private void ApplySoftDelete(DbContext? context)
    {
        if (context is null) return;

        var deletedEntries = context.ChangeTracker
            .Entries<ISoftDeletable>()
            .Where(e => e.State == EntityState.Deleted);

        foreach (var entry in deletedEntries)
        {
            // Converte DELETE para UPDATE transparentemente
            entry.State = EntityState.Modified;
            entry.Entity.IsDeleted = true;
            entry.Entity.DeletedAt = DateTime.UtcNow;
            entry.Entity.DeletedBy = _currentUser.UserId ?? "system";
        }
    }
}

// Para filtrar registros deletados automaticamente nas queries:
// Configure no OnModelCreating do DbContext com Global Query Filter
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Aplica filtro global para todas as entidades ISoftDeletable
    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
            continue;

        // EF Core gera automaticamente: WHERE IsDeleted = false em todas as queries
        var parameter = Expression.Parameter(entityType.ClrType, "e");
        var property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted));
        var notDeleted = Expression.Equal(property, Expression.Constant(false));
        var lambda = Expression.Lambda(notDeleted, parameter);

        modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
    }
}

// Para consultar incluindo deletados (em relatórios, admin etc.):
// dbContext.Orders.IgnoreQueryFilters().Where(...);

IDbCommandInterceptor: query tagging e logging de queries lentas

// Infrastructure/Interceptors/QueryTaggingInterceptor.cs
// Adiciona informação de origem (feature, endpoint) no SQL como comentário
// — aparece nos logs do banco de dados para facilitar debugging

public class QueryTaggingInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        TagCommand(command);
        return base.ReaderExecuting(command, eventData, result);
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken ct = default)
    {
        TagCommand(command);
        return base.ReaderExecutingAsync(command, eventData, result, ct);
    }

    private static void TagCommand(DbCommand command)
    {
        // Adiciona TraceId do OpenTelemetry no SQL — correlaciona query com trace
        var traceId = Activity.Current?.TraceId.ToString();
        if (traceId is not null)
            command.CommandText = $"/* traceId:{traceId} */\n{command.CommandText}";
    }
}

// Infrastructure/Interceptors/SlowQueryInterceptor.cs
// Loga queries que demoram mais que o threshold configurado
public class SlowQueryInterceptor : DbCommandInterceptor
{
    private readonly ILogger<SlowQueryInterceptor> _logger;
    private readonly TimeSpan _threshold;

    public SlowQueryInterceptor(
        ILogger<SlowQueryInterceptor> logger,
        IConfiguration configuration)
    {
        _logger = logger;
        _threshold = TimeSpan.FromMilliseconds(
            configuration.GetValue("EfCore:SlowQueryThresholdMs", 500));
    }

    public override DbDataReader ReaderExecuted(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result)
    {
        LogIfSlow(command, eventData.Duration);
        return base.ReaderExecuted(command, eventData, result);
    }

    public override ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken ct = default)
    {
        LogIfSlow(command, eventData.Duration);
        return base.ReaderExecutedAsync(command, eventData, result, ct);
    }

    private void LogIfSlow(DbCommand command, TimeSpan duration)
    {
        if (duration < _threshold) return;

        _logger.LogWarning(
            "Slow query detected: {Duration}ms (threshold: {Threshold}ms)\n{Sql}",
            duration.TotalMilliseconds,
            _threshold.TotalMilliseconds,
            command.CommandText);
    }
}

Registro de interceptors no DbContext

// Program.cs — registro limpo via DI
builder.Services.AddScoped<AuditInterceptor>();
builder.Services.AddScoped<SoftDeleteInterceptor>();
builder.Services.AddSingleton<QueryTaggingInterceptor>();
builder.Services.AddSingleton<SlowQueryInterceptor>();

builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
    options.UseNpgsql(connectionString);

    // Registra todos os interceptors via DI
    options.AddInterceptors(
        serviceProvider.GetRequiredService<AuditInterceptor>(),
        serviceProvider.GetRequiredService<SoftDeleteInterceptor>(),
        serviceProvider.GetRequiredService<QueryTaggingInterceptor>(),
        serviceProvider.GetRequiredService<SlowQueryInterceptor>());
});

// ICurrentUserService — abstração para obter o usuário autenticado atual
public interface ICurrentUserService
{
    string? UserId { get; }
    string? UserName { get; }
}

// Implementação que lê do HttpContext
public class CurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CurrentUserService(IHttpContextAccessor httpContextAccessor)
        => _httpContextAccessor = httpContextAccessor;

    public string? UserId =>
        _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value;

    public string? UserName =>
        _httpContextAccessor.HttpContext?.User.FindFirst("name")?.Value;
}

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();

Testando interceptors

public class AuditInterceptorTests
{
    [Fact]
    public async Task SaveChanges_NewEntity_SetsCreatedAtAndCreatedBy()
    {
        // Arrange — banco em memória + interceptor com usuário mockado
        var currentUser = new Mock<ICurrentUserService>();
        currentUser.Setup(u => u.UserId).Returns("user-123");

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .AddInterceptors(new AuditInterceptor(currentUser.Object))
            .Options;

        await using var context = new AppDbContext(options);

        // Act
        var order = new Order { /* ... */ };
        context.Orders.Add(order);
        await context.SaveChangesAsync();

        // Assert
        order.CreatedBy.Should().Be("user-123");
        order.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
        order.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
    }

    [Fact]
    public async Task SaveChanges_DeleteSoftDeletable_ConvertsToBooleanFlag()
    {
        var currentUser = new Mock<ICurrentUserService>();
        currentUser.Setup(u => u.UserId).Returns("admin");

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .AddInterceptors(new SoftDeleteInterceptor(currentUser.Object))
            .Options;

        await using var context = new AppDbContext(options);
        var product = new Product { Id = Guid.NewGuid(), Name = "Test" };
        context.Products.Add(product);
        await context.SaveChangesAsync();

        // Act — "delete" deve ser convertido em soft delete
        context.Products.Remove(product);
        await context.SaveChangesAsync();

        // Assert — registro existe, mas marcado como deletado
        var found = await context.Products
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(p => p.Id == product.Id);

        found.Should().NotBeNull();
        found!.IsDeleted.Should().BeTrue();
        found.DeletedBy.Should().Be("admin");
    }
}

Interceptors são o padrão mais limpo para cross-cutting concerns no EF Core. Eles mantêm o código de infraestrutura separado do domínio, são composáveis, testáveis independentemente, e não exigem que o DbContext seja sobrescrito para cada novo comportamento. Uma vez configurados, auditoria e soft delete funcionam para todas as entidades que implementam a interface — sem nenhum código adicional.


Arquitetura limpa no .NET começa por manter as entidades de domínio livres de preocupações de infraestrutura. Se você quer revisar a arquitetura do seu projeto, a Neryx pode ajudar.

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.