Interview Question
What are EF Core Interceptors used for?
Answer
EF Core Interceptors provide a way to intercept, monitor, and modify operations performed by Entity Framework Core before they’re executed against the database.
Purpose of Interceptors
Interceptors allow you to implement cross-cutting concerns without modifying your application’s core data access logic. They’re particularly useful for:
- Logging and diagnostics
- Auditing database operations
- Modifying commands before execution
- Implementing multi-tenancy
- Setting session-level database configurations
Types of Interceptors
1. DbCommandInterceptor
Intercepts database commands before and after execution:
public class CommandLoggingInterceptor : DbCommandInterceptor
{
private readonly ILogger<CommandLoggingInterceptor> _logger;
public CommandLoggingInterceptor(ILogger<CommandLoggingInterceptor> logger)
{
_logger = logger;
}
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
_logger.LogInformation("Executing command: {CommandText}", command.CommandText);
return result;
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Executing command: {CommandText}", command.CommandText);
return ValueTask.FromResult(result);
}
}
2. SaveChangesInterceptor
Intercepts SaveChanges operations:
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
AddAuditData(eventData.Context);
return result;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
AddAuditData(eventData.Context);
return ValueTask.FromResult(result);
}
private void AddAuditData(DbContext context)
{
var entries = context.ChangeTracker.Entries()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
var currentUser = GetCurrentUser(); // From your auth system
var timestamp = DateTime.UtcNow;
foreach (var entry in entries)
{
if (entry.Entity is IAuditable auditableEntity)
{
if (entry.State == EntityState.Added)
{
auditableEntity.CreatedBy = currentUser;
auditableEntity.CreatedAt = timestamp;
}
auditableEntity.LastModifiedBy = currentUser;
auditableEntity.LastModifiedAt = timestamp;
}
}
}
private string GetCurrentUser()
{
// Get current user from your authentication system
return "system"; // Placeholder
}
}
3. ConnectionInterceptor
Intercepts database connection operations:
public class ConnectionInterceptor : DbConnectionInterceptor
{
public override InterceptionResult ConnectionOpening(
DbConnection connection,
ConnectionEventData eventData,
InterceptionResult result)
{
// Set connection-specific settings
if (connection is SqlConnection sqlConnection)
{
// Example: Set application name
var builder = new SqlConnectionStringBuilder(connection.ConnectionString)
{
ApplicationName = "MyApplication"
};
connection.ConnectionString = builder.ConnectionString;
}
return result;
}
}
4. TransactionInterceptor
Intercepts database transaction operations:
public class TransactionInterceptor : DbTransactionInterceptor
{
private readonly ILogger<TransactionInterceptor> _logger;
public TransactionInterceptor(ILogger<TransactionInterceptor> logger)
{
_logger = logger;
}
public override InterceptionResult TransactionStarting(
DbTransaction transaction,
TransactionEventData eventData,
InterceptionResult result)
{
_logger.LogInformation("Starting transaction at {Time}", DateTime.UtcNow);
return result;
}
public override void TransactionCommitted(
DbTransaction transaction,
TransactionEndEventData eventData)
{
_logger.LogInformation("Transaction committed at {Time}", DateTime.UtcNow);
}
public override void TransactionRolledBack(
DbTransaction transaction,
TransactionEndEventData eventData)
{
_logger.LogWarning("Transaction rolled back at {Time}", DateTime.UtcNow);
}
}
Real-World Example: Multi-Tenant Application
// Multi-tenant command interceptor
public class MultiTenantCommandInterceptor : DbCommandInterceptor
{
private readonly ICurrentTenantProvider _tenantProvider;
public MultiTenantCommandInterceptor(ICurrentTenantProvider tenantProvider)
{
_tenantProvider = tenantProvider;
}
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
ApplyTenantFilter(command);
return result;
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
ApplyTenantFilter(command);
return ValueTask.FromResult(result);
}
private void ApplyTenantFilter(DbCommand command)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
// Only modify SELECT commands
if (!command.CommandText.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
return;
// Don't modify if it already has a tenant filter
if (command.CommandText.Contains("TenantId =") || command.CommandText.Contains("@tenant"))
return;
// Simple approach: Add tenant filter to WHERE clause
if (command.CommandText.Contains("WHERE"))
{
command.CommandText = command.CommandText.Replace(
"WHERE", $"WHERE TenantId = @tenant AND");
}
else if (command.CommandText.Contains("FROM"))
{
// Find position after FROM clause and table name
var fromIndex = command.CommandText.IndexOf("FROM");
var wherePosition = command.CommandText.IndexOf("ORDER BY");
if (wherePosition == -1) wherePosition = command.CommandText.Length;
command.CommandText = command.CommandText.Insert(
wherePosition, $" WHERE TenantId = @tenant ");
}
// Add parameter
var parameter = command.CreateParameter();
parameter.ParameterName = "@tenant";
parameter.Value = tenantId;
command.Parameters.Add(parameter);
}
}
// Register in Program.cs or Startup.cs
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString);
options.AddInterceptors(
new MultiTenantCommandInterceptor(tenantProvider),
new AuditSaveChangesInterceptor());
});
Key Points 💡
- Interceptors provide a clean way to implement cross-cutting concerns
- They can modify commands before execution or just observe operations
- Multiple interceptors can be registered and will execute in registration order
- Interceptors are powerful but should be used judiciously due to performance impact
- Available since EF Core 3.0 with expanded capabilities in later versions
- They’re ideal for auditing, logging, multi-tenancy, and diagnostics
- Interceptors can be registered globally or per-context
Common Follow-up Questions
- How do interceptors differ from database triggers?
- What’s the performance impact of using interceptors?
- Can interceptors be used to implement row-level security?
- How would you test code that uses interceptors?
Test Your Knowledge
Take a quick quiz to test your understanding of this topic.