In enterprise applications, every time a record is saved or a SQL query fires, you often need to execute global logic. For example: Setting "LastModified" dates, logging slow SQL queries, or writing Audit Trails for compliance frameworks. EF Core Interceptors allow you to hijack the DbContext pipeline right before, or right after, a database operation occurs.
The most common interceptor hooks into the SaveChanges pipeline. We can analyze the Change Tracker and manipulate objects right before they are translated into SQL.
public class AuditingInterceptor : SaveChangesInterceptor
{
// This executes just milliseconds before the SQL INSERT/UPDATE fires
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken)
{
var context = eventData.Context;
// Scan the ChangeTracker for any object implementing 'IAuditable'
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedDate = DateTime.UtcNow;
entry.Entity.CreatedBy = "SystemUser";
}
if (entry.State == EntityState.Modified)
{
entry.Entity.ModifiedDate = DateTime.UtcNow;
entry.Entity.ModifiedBy = "SystemUser";
}
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
What if you want to inspect every single raw SQL query that fires out of your API, and log an alert in Application Insights if a query takes longer than 2 seconds?
public class SlowQueryInterceptor : DbCommandInterceptor
{
// Executes EXACTLY when the SQL query finishes returning from SQL Server
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken)
{
if (eventData.Duration > TimeSpan.FromSeconds(2))
{
// Log the Warning! We can print out the exact SQL Command that was slow.
Console.WriteLine($"SLOW QUERY ALERT: {eventData.Duration.TotalMilliseconds}ms\n{command.CommandText}");
}
return await base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}
Interceptors must be formally attached to the DbContext during Dependency Injection registration.
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer("...")
.AddInterceptors(new AuditingInterceptor(), new SlowQueryInterceptor());
});
Q: "We wrote a `SaveChangesInterceptor` to write a historical log entry into an `AuditLogs` table every time a User is updated. However, the `ApplicationDbContext` cannot be injected into the Interceptor via Dependency Injection, resulting in a Null Reference Exception. How do we cleanly save new Audit Logs?"
Architect Answer: "You absolutely shouldn't manually inject the `ApplicationDbContext` into your Interceptor class constructor because it creates a circular dependency; the DbContext configures the Interceptor, and the Interceptor configures the DbContext. Instead, the `DbContextEventData eventData` parameter passed natively into the Interceptor execution method physically contains the runtime instance of the DbContext! You simply extract it via `var context = eventData.Context;`, instantiate a new `AuditLog` object, and push it directly into `context.Add(newLog);`. The framework natively folds this new injection directly into the exact same SQL Transaction that is currently saving the user."