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.