DEV Community

Cover image for A Comprehensive Guide to Interceptors in EF Core
Zied Rebhi
Zied Rebhi

Posted on

A Comprehensive Guide to Interceptors in EF Core

Entity Framework Core (EF Core) is a powerful and flexible Object-Relational Mapper (ORM) for .NET developers. Among its features, interceptors provides a mechanism to tap into the EF Core pipeline, allowing developers to inspect, modify, or augment EF Core operations. This guide explains interceptors, how they work, and their practical applications with examples. We’ll also explore the benefits and pitfalls of using interceptors.

What Are Interceptors in EF Core?

Interceptors in EF Core are hooks into the internal processes of EF Core. They allow developers to execute custom logic during various EF Core operations such as database commands, queries, or save operations.

Interceptors work similarly to middleware. They intercept operations, giving you a chance to:

  • Log data
  • Validate operations
  • Modify behavior

Unlike EF Core events (e.g., SaveChanges or Querying), interceptors are more granular and designed for low-level access to EF Core operations.

Key Types of Interceptors

EF Core provides severals built-in interfaces to define interceptors:

  1. IDbCommandInterceptor: For database command execution.
  2. ISaveChangesInterceptor: For intercepting SaveChanges operations.
  3. IQueryExpressionInterceptor: For intercepting LINQ query expressions.
  4. IConnectionInterceptor: For database connection operations.
  5. ITransactionInterceptor: For intercepting transaction-related operations.

Setting Up a Sample .NET Web API with EF Core

Let’s start by creating a clean .NET Web API project using EF Core and then integrate interceptors.

Step 1: Create a New Web API Project

Run the following commands to create a new .NET Web API project

mkdir EfCoreInterceptors
cd EfCoreInterceptors
dotnet new webapi

Enter fullscreen mode Exit fullscreen mode

Step 2: Add EF Core and Database Provider

Install EF Core and a database provider, such as SQL Server:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
Enter fullscreen mode Exit fullscreen mode

Step 3: Create the DbContext and Entity Classes
Define a Product entity and AppDbContext:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public bool IsDeleted { get; set; } // For soft delete functionality
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
}

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

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

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted); // Soft delete query filter
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Configure the Database Connection

Update Program.cs to configure the database connection:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseAuthorization();

app.MapControllers();

app.Run();
Enter fullscreen mode Exit fullscreen mode

Add a connection string to appsettings.json:

"ConnectionStrings": {
  "DefaultConnection": "Server=localhost;Database=EfCoreDemo;Trusted_Connection=True;"
}
Enter fullscreen mode Exit fullscreen mode

Adding Interceptors

Example 1: Logging SQL Queries with IDbCommandInterceptor

Let’s add a simple interceptor to log all SQL commands executed by EF Core.

using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Data.Common;

public class SqlLoggingInterceptor : IDbCommandInterceptor
{
    public override DbCommand CommandCreated(CommandEndEventData eventData, DbCommand result)
    {
        Console.WriteLine($"SQL Command Created: {result.CommandText}");
        return result;
    }

    public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        Console.WriteLine($"Executing SQL Command: {command.CommandText}");
        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

Registering the Interceptor

Register the interceptor in AppDbContext:

public class AppDbContext : DbContext
{
    private readonly IDbCommandInterceptor _sqlLoggingInterceptor;

    public AppDbContext(DbContextOptions<AppDbContext> options, IDbCommandInterceptor sqlLoggingInterceptor)
        : base(options)
    {
        _sqlLoggingInterceptor = sqlLoggingInterceptor;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.AddInterceptors(_sqlLoggingInterceptor);
    }

    public DbSet<Product> Products { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Update Program.cs to inject the interceptor:

builder.Services.AddSingleton<IDbCommandInterceptor, SqlLoggingInterceptor>();
Enter fullscreen mode Exit fullscreen mode

Example 2: Detecting Slow Queries

This interceptor logs queries that take longer than a specified threshold and triggers an alert (e.g., sends an email or logs to an external monitoring service) for production monitoring.

using System.Diagnostics;

public class SlowQueryInterceptor : IDbCommandInterceptor
{
    private readonly TimeSpan _threshold = TimeSpan.FromMilliseconds(500);
    private readonly IMonitoringService _monitoringService;

    public SlowQueryInterceptor(IMonitoringService monitoringService)
    {
        _monitoringService = monitoringService;
    }

    public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        var stopwatch = Stopwatch.StartNew();
        try
        {
            return base.ReaderExecuting(command, eventData, result);
        }
        finally
        {
            stopwatch.Stop();
            if (stopwatch.Elapsed > _threshold)
            {
                var message = $"Slow Query Detected: {command.CommandText} took {stopwatch.ElapsedMilliseconds} ms";
                Console.WriteLine(message);

                // Trigger alert to monitoring service
                _monitoringService.LogSlowQuery(message);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Implementation Notes

IMonitoringService: Define a monitoring service interface to abstract the alerting mechanism. For example:

public interface IMonitoringService
{
    void LogSlowQuery(string message);
}
Enter fullscreen mode Exit fullscreen mode

Concrete Monitoring Service: Implement the interface using tools like email services or external monitoring platforms (e.g., Application Insights, or custom email notifications).

public class EmailMonitoringService : IMonitoringService
{
    public void LogSlowQuery(string message)
    {
        // Implement email or external logging here
        Console.WriteLine($"Alert sent: {message}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Register Services: Register both the interceptor and the monitoring service in Program.cs:

builder.Services.AddSingleton<IMonitoringService, EmailMonitoringService>();
builder.Services.AddSingleton<IDbCommandInterceptor, SlowQueryInterceptor>();
Enter fullscreen mode Exit fullscreen mode

This interceptor logs queries that take longer than a specified threshold.

public class SlowQueryInterceptor : IDbCommandInterceptor
{
    private readonly TimeSpan _threshold = TimeSpan.FromMilliseconds(500);

    public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        var stopwatch = Stopwatch.StartNew();
        var readerResult = base.ReaderExecuting(command, eventData, result);
        stopwatch.Stop();

        if (stopwatch.Elapsed > _threshold)
        {
            Console.WriteLine($"Slow Query Detected: {command.CommandText} took {stopwatch.ElapsedMilliseconds} ms");
        }

        return readerResult;
    }
}
Enter fullscreen mode Exit fullscreen mode

Example 3: Implementing Soft Delete with ISaveChangesInterceptor

Soft delete logic can be particularly useful in multi-tenant systems or in cases where complex relationships require handling cascading deletes safely. This example shows how to intercept _SaveChanges _to implement soft delete functionality while considering these scenarios.

public class SoftDeleteInterceptor : ISaveChangesInterceptor
{
    public override int SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        var context = eventData.Context;
        if (context == null) return base.SavingChanges(eventData, result);

        foreach (var entry in context.ChangeTracker.Entries())
        {
            // Handle entities with IsDeleted property
            if (entry.Entity is ISoftDeletable softDeletableEntity && entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                softDeletableEntity.IsDeleted = true;

                // Update related entities if required for complex relationships
                if (entry.Entity is ITenantEntity tenantEntity)
                {
                    HandleTenantSpecificLogic(context, tenantEntity);
                }
            }
        }

        return base.SavingChanges(eventData, result);
    }

    private void HandleTenantSpecificLogic(DbContext context, ITenantEntity tenantEntity)
    {
        // Example: Ensure related tenant-specific records are soft-deleted or flagged appropriately
        var relatedEntities = context.ChangeTracker.Entries()
            .Where(e => e.Entity is IRelatedEntity && ((IRelatedEntity)e.Entity).TenantId == tenantEntity.TenantId);

        foreach (var relatedEntity in relatedEntities)
        {
            if (relatedEntity.State == EntityState.Deleted)
            {
                relatedEntity.State = EntityState.Modified;
                ((IRelatedEntity)relatedEntity.Entity).IsDeleted = true;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Considerations for Multi-Tenant Systems:

  • Tenant Isolation: Ensure that soft delete operations respect tenant boundaries to avoid accidental cross-tenant modifications.

  • ** Query Filters:** Use global query filters to exclude soft-deleted entities based on the tenant context.

modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted && p.TenantId == currentTenantId);
Enter fullscreen mode Exit fullscreen mode

Register the Interceptor

Register the interceptor in Program.cs as before:

builder.Services.AddSingleton<ISaveChangesInterceptor, SoftDeleteInterceptor>();
Enter fullscreen mode Exit fullscreen mode

Example 4: Auditing Changes with ISaveChangesInterceptor

Track who created or updated entitiess.

public class AuditInterceptor : ISaveChangesInterceptor
{
    public override int SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        var context = eventData.Context;
        if (context == null) return base.SavingChanges(eventData, result);

        foreach (var entry in context.ChangeTracker.Entries<Product>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.CurrentValues["CreatedAt"] = DateTime.UtcNow;
            }

            if (entry.State == EntityState.Modified)
            {
                entry.CurrentValues["UpdatedAt"] = DateTime.UtcNow;
            }
        }

        return base.SavingChanges(eventData, result);
    }
}
Enter fullscreen mode Exit fullscreen mode

Example 5: Data Masking for Sensitive Data

Mask sensitive data when it’s queried from the database.

public class DataMaskingInterceptor : IDbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
    {
        // Example: Masking sensitive fields like SSN or Email
        var maskedReader = new MaskedDbDataReader(result);
        return InterceptionResult<DbDataReader>.Success(maskedReader);
    }
}

public class MaskedDbDataReader : DbDataReader
{
    private readonly DbDataReader _innerReader;

    public MaskedDbDataReader(DbDataReader innerReader)
    {
        _innerReader = innerReader;
    }

    public override string GetString(int ordinal)
    {
        var columnName = _innerReader.GetName(ordinal);
        var value = _innerReader.GetString(ordinal);

        if (columnName == "SSN")
        {
            return "***-**-****"; // Mask SSN
        }

        if (columnName == "Email")
        {
            return "****@example.com"; // Mask email
        }

        return value;
    }

    // Delegate all other methods to _innerReader
    public override bool Read() => _innerReader.Read();
    // Implement other necessary methods...
}
Enter fullscreen mode Exit fullscreen mode

Register the interceptor as before.


Example 6: Retry Logic for Transient Failures

Handle transient failures (e.g., database connection drops) with retry logic.

public class RetryInterceptor : IDbCommandInterceptor
{
    private const int MaxRetryCount = 3;

    public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        for (int retry = 0; retry < MaxRetryCount; retry++)
        {
            try
            {
                return base.ReaderExecuting(command, eventData, result);
            }
            catch (DbException ex)
            {
                if (retry == MaxRetryCount - 1) throw;
                Console.WriteLine($"Retrying due to transient failure: {ex.Message}");
            }
        }

        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

Advantages of Interceptors

  1. Centralized Logic: Write logic once and apply it across the DbContext.
  2. Enhanced Debugging: Log queries, commands, and EF Core operations for better diagnostics.
  3. Custom Behavior: Modify or validate EF Core operations without polluting business logic.
  4. Security: Enforce rules such as preventing sensitive data deletion or unauthorized queries.

Potential Pitfalls

  1. Complexity: Interceptors introduce additional complexity and may confuse developers unfamiliar with the EF Core pipeline.
  2. Performance Overhead: Excessive processing within interceptors can degrade application performance.
  3. Maintenance Challenges: Changes to EF Core or application requirements may necessitate updates to interceptors.
  4. Limited Scope: Some interceptors, like IDbCommandInterceptor, provide access only at a low level, requiring knowledge of database internals.

Best Practices

  • Use Interceptors Sparingly: Only use them for cross-cutting concerns or operations that cannot be handled elsewhere.
  • Test Extensively: Ensure interceptors do not introduce unintended side effect.
  • Log Strategically: Avoid over-logging to maintain performances.
  • Keep Code Readable: Use clear and concise logic in interceptor.

Thank you .

Top comments (0)