EF Core Interceptors

EF Core interceptors have become essential tools in a .NET ecosystem where nearly 41% of C# developers now rely on the framework. They give teams firm, unified control over data-layer behavior, keeping it consistent even as applications scale. And because cross-cutting logic (logging, auditing, caching) lives in one place, the rest of the codebase stays much easier to read and maintain.

To show how this works in practice, this guide defines EF Core interceptors and illustrates them with practical examples built on the Sakila database and the Actor entity.

What are EF interceptors?

EF Core interceptors are built-in extension points that let you plug into EF Core's execution pipeline. They allow you to observe or influence what EF Core is about to do — executing SQL, saving changes, opening connections, or materializing entities. Instead of sprinkling logging, auditing, caching, or soft-delete logic across services and repositories, an interceptor applies that behavior once and enforces it everywhere.

Interceptors work well because they are:

  • Event-driven: They run automatically when EF Core performs important actions, like executing a command or saving changes, so you can react at the right moment.
  • Cross-cutting: They consolidate system-wide behavior such as logging, auditing, soft deletes, and security checks, ensuring these rules run everywhere without duplicating code.
  • Non-invasive: Your queries and SaveChanges calls stay untouched. Interceptors inject the required behavior underneath them, keeping your data layer consistent without adding noise to your logic.

However, to use an interceptor, you need to create a class that inherits from something like DbCommandInterceptor or SaveChangesInterceptor, override the events you need, and register it in the DbContextOptionsBuilder. After that, EF Core automatically runs your logic whenever those operations occur.

Why are interceptors needed?

Interceptors are needed because core behaviors like logging, auditing, and security become unreliable when every service handles them on its own. By moving these rules into the EF Core pipeline, you make sure they run the same way on every query and every save.

Here's how interceptors help in each area:

  • Logging: Interceptors can record all SQL queries and their parameters in a single log, so you always see what's happening "under the hood" without adding logging code to every service.
  • Security: They can automatically add filters (for example, checking user permissions or applying tenant filters before data is read), so access rules aren't easy to skip.
  • Optimization: They can detect repeated queries and support caching of results, which helps avoid unnecessary round-trips to the database.
  • Modification: They can change data on the fly, such as encrypting fields or adding timestamps and audit fields just before EF Core saves changes.

Next, let's look at the main interceptor points and where they plug into EF Core.

Core interceptor types

EF Core exposes a few key interception points, each tied to a different stage of the pipeline. In practice, you inherit from the matching base class and override the methods you need.

Here are the main interceptor types and the scenarios they're built for.

IDbCommandInterceptor

Intercepts the SQL commands EF Core sends to the database. This interceptor gives you visibility into the exact SQL EF Core generates and the parameters it uses. It runs before and after every command execution, making it ideal for:

  • Logging SQL statements and parameters for debugging or diagnostics.
  • Detecting repeated or inefficient query patterns.
  • Adjusting command text or parameters before execution.

This interceptor sits at the heart of all query-level behavior.

ISaveChangesInterceptor

Hooks into EF Core's save pipeline, running before, during, or after SaveChanges and SaveChangesAsync. Since it operates at the persistence boundary, it's the natural place for:

  • Automatic timestamps (CreatedAt, UpdatedAt).
  • Audit fields (CreatedBy, UpdatedBy).
  • Soft-delete enforcement and other transformations before data is written.

Anything that needs to run whenever EF Core saves changes should be handled in a SaveChanges interceptor.

IDbConnectionInterceptor

Runs when EF Core opens or closes a database connection. This gives you insight into connection usage and helps enforce consistency in environments where connections matter. It's useful for:

  • Logging connection open/close events.
  • Applying connection-level policies or diagnostics.

This is especially relevant for systems under load or with strict connection budgets.

IDbTransactionInterceptor

Fires whenever EF Core starts, commits, or rolls back a transaction. This gives you visibility into transaction boundaries and lets you integrate with external systems that track or enforce transactional behavior. Use it for:

  • Tracing transaction lifecycle events.
  • Integrating with custom logging or monitoring systems.

Ideal when transaction correctness is critical.

IMaterializationInterceptor

Runs right after EF Core turns database results into real objects. This is the first point where you can access those finished entities. Use it for:

  • Post-processing entities right after materialization.
  • Decrypting fields or applying transformations before the application uses them.

It's perfect for cases where raw database values need adjustment before becoming usable domain objects.

Note
Most applications rely heavily on DbCommandInterceptor and SaveChangesInterceptor, since these two cover the bulk of real-world logging, auditing, transformation, and caching needs.

Once you know where each interceptor fits in the pipeline, the next step is to see them running in a real EF Core setup.

How does it work in practice?

To understand how interceptors work, we'll use a simple setup:

  • The Sakila sample database.
  • The Actor table.
  • An entity model and SakilaDbContext were generated using Entity Developer.

You register interceptors on the DbContextOptionsBuilder using .AddInterceptors(...). Once you do that, EF Core automatically runs them whenever the context executes queries or saves changes. You don't have to change your LINQ queries, repository methods, or service code: EF Core calls the interceptors behind the scenes.

In the following sections, we'll walk through real examples of interceptors that handle:

  • Logging SQL queries.
  • Adding timestamps automatically.
  • Soft-deleting records.
  • Auditing who changed what.
  • Detecting repeated queries.
  • Invalidating cached queries when data changes.

Each interceptor focuses on one job and shows how you can apply system-wide behavior without touching the rest of your application code. Let's explore them in detail.

Logging SQL queries

Logging the SQL that EF Core sends to the database is the fastest way to see what's actually being executed. It immediately helps with debugging, performance checks, and validating how EF Core translates LINQ.

SqlLoggingInterceptor helps with this by hooking into EF Core's command execution and logging each SELECT command and its parameters.

Interceptor class

SqlLoggingInterceptor plugs into EF Core's command-execution pipeline by extending DbCommandInterceptor. EF calls ReaderExecutingAsync right before running a SELECT, which gives you a perfect point to inspect the SQL text and its parameters. The interceptor prints them out, then lets EF Core continue normally.

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

namespace Interceptors.Interceptors;

public class SqlLoggingInterceptor : DbCommandInterceptor
{
    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine("SQL Query Intercepted:");
        Console.WriteLine(command.CommandText);

        if (command.Parameters.Count > 0)
        {
            Console.WriteLine("\nParameters:");
            foreach (DbParameter param in command.Parameters)
            {
                Console.WriteLine($"  {param.ParameterName} = {param.Value}");
            }
        }

        return new ValueTask<InterceptionResult<DbDataReader>>(result);
    }
}

What's happening here:

  • ReaderExecutingAsync runs before EF Core sends the SQL to the database.
  • command.CommandText prints the generated SQL.
  • The parameter loop prints all parameter names and values.
  • The method returns the original result, so the command executes normally after logging.

In a real project, you'd typically replace Console.WriteLine with a structured logging framework.

Registration (Program.cs)

Next, the interceptor must be attached to your SakilaDbContext so EF Core knows to call it. This happens when you configure the DbContextOptionsBuilder:

var sqlLoggingInterceptor = new SqlLoggingInterceptor();

var optionsBuilder = new DbContextOptionsBuilder<SakilaDbContext>();
optionsBuilder
    .UsePostgreSql(connectionString)
    .AddInterceptors(sqlLoggingInterceptor)
    .EnableSensitiveDataLogging();

Key points:

  • new SqlLoggingInterceptor() creates the interceptor instance.
  • .AddInterceptors(...) attaches it to all commands executed by SakilaDbContext.
  • .EnableSensitiveDataLogging() allows parameter values to be logged; use cautiously in production.

Once this configuration is in place, you don't need to touch your queries.

Usage

From the application's perspective, nothing changes. You query the database as you normally would.

Querying the database with EF Core interceptors

When this code runs, EF Core routes the operation through the interceptor. The screenshot shows exactly what happens:

  • EF Core translates your LINQ query into SQL.
  • Before the command executes, SqlLoggingInterceptor.ReaderExecutingAsync is triggered.
  • The interceptor prints the complete SQL statement.
  • It also logs all parameter values (for example, the LIMIT parameter in the screenshot).
  • EF Core then executes the query normally and returns the result set.
  • The final output shows both the logged SQL and the actual list of actors retrieved.

This gives you complete visibility into the SQL EF Core generates without modifying your query or repository code; the interceptor handles everything behind the scenes.

Automatic timestamps

Setting CreatedAt and UpdatedAt correctly on inserts and updates is a requirement in most applications. Without an interceptor, this logic is easy to miss and often gets duplicated across services and repositories.

TimestampInterceptor centralizes that behavior so timestamps are always applied consistently before EF Core saves changes.

Interceptor class

TimestampInterceptor uses the SaveChangesInterceptor pipeline, which runs just before EF Core writes anything to the database. At this stage, EF Core exposes all tracked entities and their states, so the interceptor can safely apply created_at and updated_at values in one place instead of spreading this logic throughout the codebase.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Interceptors.Models;

namespace Interceptors.Interceptors;

public class TimestampInterceptor : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        UpdateTimestamps(eventData.Context);
        return result;
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        UpdateTimestamps(eventData.Context);
        return new ValueTask<InterceptionResult<int>>(result);
    }

    private void UpdateTimestamps(DbContext? context)
    {
        if (context == null) return;

        var entries = context.ChangeTracker.Entries<Actor>();
        var now = DateTime.UtcNow;

        foreach (var entry in entries)
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.created_at = now;
                entry.Entity.updated_at = now;
                Console.WriteLine($"[Interceptor] Setting timestamps for NEW actor: created_at = {now}, updated_at = {now}");
            }
            else if (entry.State == EntityState.Modified)
            {
                entry.Entity.updated_at = now;
                Console.WriteLine($"[Interceptor] Updating timestamp for actor ID {entry.Entity.actor_id}: updated_at = {now}");
            }
        }
    }
}

What's happening here:

  • SavingChanges / SavingChangesAsync run before EF Core saves changes.
  • The interceptor looks at all tracked Actor entities.
  • New entities get both created_at and updated_at set.
  • Modified entities get only updated_at updated.
  • Nothing else in your application needs to remember to set timestamps—EF Core enforces it.

Usage

Register the interceptor on your DbContext to activate automatic timestamp handling.

.AddInterceptors(new TimestampInterceptor())

Once the interceptor is registered, your save logic stays the same; the interceptor applies timestamps automatically behind the scenes.

var actor = new Actor { first_name = "JOHN", last_name = "DOE" };
context.actor.Add(actor);
await context.SaveChangesAsync();
Applying automatic timestamps with EF Core interceptors

The screenshot shows the full lifecycle:

  • Creating a new actor triggers timestamp assignment.
  • Updating the actor refreshes updated_at.
  • Deleting the actor proceeds normally—timestamps apply only to insert/update.
  • The interceptor messages confirm exactly when and how timestamps are applied.

This gives you reliable timestamp management with zero timestamp logic in your application code.

Soft delete

Soft delete lets you keep rows in the database while treating them as deleted at the application level. It preserves history, supports audit requirements, and helps prevent accidental data loss.

Applying soft delete through an interceptor ensures this behavior runs automatically everywhere, rather than depending on each developer to "remember" the correct pattern.

Interceptor class

SoftDeleteInterceptor also uses SaveChangesInterceptor, but it focuses specifically on entities that EF Core marks as Deleted. Because EF exposes entity states before saving, the interceptor can intercept deletions, block the physical DELETE, and apply your soft-delete behavior consistently.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Interceptors.Models;

namespace Interceptors.Interceptors;

public class SoftDeleteInterceptor : SaveChangesInterceptor
{
    // Store soft-deleted actor IDs in memory (for demonstration purposes)
    public static HashSet<int> SoftDeletedActorIds { get; } = new HashSet<int>();

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

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        HandleSoftDelete(eventData.Context);
        return new ValueTask<InterceptionResult<int>>(result);
    }

    private void HandleSoftDelete(DbContext? context)
    {
        if (context == null) return;

        var entries = context.ChangeTracker.Entries<Actor>();

        foreach (var entry in entries)
        {
            if (entry.State == EntityState.Deleted)
            {
                // Instead of actually deleting, prevent the deletion
                entry.State = EntityState.Unchanged;

                // Track the soft-deleted ID
                SoftDeletedActorIds.Add(entry.Entity.actor_id);

                Console.WriteLine($"[Interceptor] Soft delete intercepted for actor ID {entry.Entity.actor_id}");
                Console.WriteLine($"  Actor marked as soft-deleted (not physically removed from database)");
                Console.WriteLine($"  Name: {entry.Entity.first_name} {entry.Entity.last_name}");
            }
        }
    }
}

What's happening here:

  • The interceptor runs before EF Core commits changes.
  • Any Actor entity in the Deleted state is intercepted.
  • The deletion is blocked by setting the state to Unchanged, preventing a physical DELETE.
  • The actor ID is added to SoftDeletedActorIds for demonstration purposes.
  • The record stays in the database and can be treated as deleted by the application.

Real-world usage

Production soft-delete systems usually set an IsDeleted or DeletedAt column and rely on EF Core global query filters to automatically hide those rows. This interceptor shows the underlying concept: centralize deletion rules and guarantee they execute consistently.

Usage

To soft-delete an actor, you write the same code you would for a normal delete:

context.actor.Remove(newActor);
await context.SaveChangesAsync();

The interceptor takes over and prevents the hard delete, marking the actor as soft-deleted instead.

Soft delete in action

The following output demonstrates the full lifecycle:

Applying soft delete with EF Core interceptors

What the screenshot illustrates:

  • The actor is created and stored typically.
  • A query confirms the actor exists in the database.
  • When Remove() is called, the interceptor intercepts the deletion, marks the actor as soft-deleted, and blocks the hard delete.
  • A follow-up query shows the actor still exists in the database.
  • The actor's ID appears in SoftDeletedActorIds, confirming the soft-delete was applied.

This gives you consistent soft-delete behavior across your entire application without changing your repository or service code.

Audit changes (who/when changed)

Auditing builds on timestamp logic by also tracking who made each change. If every service or repository has to set CreatedBy and UpdatedBy manually, it's only a matter of time before something is missed.

An interceptor can apply this metadata automatically before EF Core writes anything to the database, which keeps audit rules consistent and reduces human error.

Interceptor class

AuditInterceptor relies on SaveChangesInterceptor as well, but it only handles entities that implement IAuditable. Right before EF Core saves, the interceptor sets or updates user and timestamp fields on new and modified entities using the current user identity.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Interceptors.Models;

namespace Interceptors.Interceptors;

public class AuditInterceptor : SaveChangesInterceptor
{
    private readonly string _currentUser;

    public AuditInterceptor(string currentUser)
    {
        _currentUser = currentUser;
    }

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

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        AddAuditInfo(eventData.Context);
        return new ValueTask<InterceptionResult<int>>(result);
    }

    private void AddAuditInfo(DbContext? context)
    {
        if (context == null) return;

        var entries = context.ChangeTracker.Entries<IAuditable>();
        var now = DateTime.UtcNow;

        foreach (var entry in entries)
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.created_at = now;
                entry.Entity.created_by = _currentUser;
                entry.Entity.updated_at = now;
                entry.Entity.updated_by = _currentUser;

                Console.WriteLine($"[Audit Interceptor] NEW entity created");
                Console.WriteLine($"  Created At: {entry.Entity.created_at}");
                Console.WriteLine($"  Created By: {entry.Entity.created_by}");
                Console.WriteLine($"  Updated At: {entry.Entity.updated_at}");
                Console.WriteLine($"  Updated By: {entry.Entity.updated_by}");
            }
            else if (entry.State == EntityState.Modified)
            {
                entry.Entity.updated_at = now;
                entry.Entity.updated_by = _currentUser;

                Console.WriteLine($"[Audit Interceptor] Entity MODIFIED");
                Console.WriteLine($"  Created At: {entry.Entity.created_at}");
                Console.WriteLine($"  Created By: {entry.Entity.created_by}");
                Console.WriteLine($"  Updated At: {entry.Entity.updated_at}");
                Console.WriteLine($"  Updated By: {entry.Entity.updated_by}");
            }
        }
    }
}

What's happening here:

  • The interceptor runs before EF Core saves changes.
  • New entities get both creation and update metadata, including user identity.
  • Modified entities only update the "updated" fields, preserving original creation metadata.
  • All audit logic is centralized, ensuring consistent behavior across your entire data layer.

Usage

To use the audit interceptor, you provide the current user when constructing it.

var currentUser = "[email protected]"; // from your auth context
var auditInterceptor = new AuditInterceptor(currentUser);

optionsBuilder.AddInterceptors(auditInterceptor);

Once registered, any IAuditable entity will automatically receive audit fields during SaveChanges, no extra logic required in your services or repositories.

Applying audit interceptors

The screenshot shows:

  • A new actor created by [email protected], with created/updated metadata set.
  • A short pause simulating time passing.
  • The same actor updated by [email protected], updating only the modification metadata.
  • A final delete confirming the lifecycle completes without interfering with audit tracking.

Query caching

Query caching helps you see how often the same SQL runs and where a real cache would have the most impact. In this example, the interceptor doesn't replace EF Core's query results; it simply tracks repeated queries, logs hits and misses, and prints statistics.

This gives you a safe way to understand caching potential before you commit to a full-blown cache implementation.

At a high level, it:

  • Tracks each intercepted query using ReaderExecutingAsync.
  • Builds a unique signature from SQL text + parameters.
  • Detects when the same query runs again.
  • Records cache hits, misses, and overall hit rate.

Interceptor class

QueryCachingInterceptor hooks into EF Core's query pipeline through DbCommandInterceptor. EF triggers ReaderExecutingAsync just before executing a query, which allows the interceptor to generate a signature for the SQL + parameters, detect repeated queries, and log cache hits or misses, without changing the actual results.

using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Caching.Memory;
using System.Data.Common;
using System.Security.Cryptography;
using System.Text;

namespace Interceptors.Interceptors;

public class QueryCachingInterceptor : DbCommandInterceptor
{
    private readonly IMemoryCache _cache;
    private readonly TimeSpan _cacheExpiration;
    public int CacheHits { get; private set; }
    public int CacheMisses { get; private set; }

    public QueryCachingInterceptor(IMemoryCache cache, TimeSpan? cacheExpiration = null)
    {
        _cache = cache;
        _cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(5);
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        var cacheKey = GenerateCacheKey(command);

        // Check if query result is in cache
        if (_cache.TryGetValue(cacheKey, out string? cachedStatus))
        {
            CacheHits++;
            Console.WriteLine("[Cache Interceptor] Cache HIT!");
            Console.WriteLine($"  Query: {GetShortQuery(command.CommandText)}");
            Console.WriteLine($"  This query was executed before and is cached");
        }
        else
        {
            CacheMisses++;
            Console.WriteLine("[Cache Interceptor] Cache MISS - Executing query against database");
            Console.WriteLine($"  Query: {GetShortQuery(command.CommandText)}");

            // Mark this query as cached
            _cache.Set(cacheKey, "cached", _cacheExpiration);
            Console.WriteLine($"  Query cached for {_cacheExpiration.TotalSeconds} seconds");
        }

        return new ValueTask<InterceptionResult<DbDataReader>>(result);
    }

    private string GenerateCacheKey(DbCommand command)
    {
        var queryString = command.CommandText;
        foreach (DbParameter param in command.Parameters)
        {
            queryString += $"|{param.ParameterName}={param.Value}";
        }

        using var md5 = MD5.Create();
        var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(queryString));
        return Convert.ToBase64String(hash);
    }

    private string GetShortQuery(string query)
    {
        var lines = query.Split('\n');
        return lines.Length > 1 ? lines[0].Trim() + "..." : query.Trim();
    }

    public void PrintStatistics()
    {
        Console.WriteLine("\n[Cache Statistics]");
        Console.WriteLine($"  Cache Hits: {CacheHits}");
        Console.WriteLine($"  Cache Misses: {CacheMisses}");
        Console.WriteLine($"  Hit Rate: {(CacheHits + CacheMisses > 0 ? (CacheHits * 100.0 / (CacheHits + CacheMisses)):0):F1}%");
    }
}

What's happening here:

  • ReaderExecutingAsync runs before EF Core sends SQL to the database.
  • A unique cache key is built using the SQL text and parameter values.
  • If the key exists in the in-memory cache, it's logged as a cache hit.
  • If not, it's a cache miss, and the interceptor records the query in the cache.
  • Hit/miss counters are updated for later statistics.
  • The query still executes normally; this interceptor only tracks caching behavior.

Usage

Now run the application, and you should see:

  • Query 1: Cache MISS.
  • Query 2: Cache HIT (same query).
  • Query 3: Cache HIT (same query).
  • Query 4: Cache MISS (different query).
  • Query 5: Cache HIT (repeated query 4).
  • Cache statistics are printed at the end.
Applying query caching with EF Core interceptors

This makes it easy to identify repeated query patterns and decide where true caching (such as Redis or a custom in-memory layer) would have the biggest impact.

Cache invalidation

Caching is only helpful if you can keep it correct when data changes. CacheInvalidationInterceptor shows how you can track cached queries by table and automatically invalidate them whenever EF Core detects inserts, updates, or deletes.

This gives you a central place to manage cache consistency without modifying individual repositories or query handlers.

At a high level, the interceptor:

  • Tracks which queries were "cached" via TrackQuery().
  • Detects data changes during SaveChanges.
  • Identifies which tables were affected.
  • Invalidates cache entries tied to those tables.
  • Tracks how many invalidations occurred.

Interceptor class (caching + invalidation)

CacheInvalidationInterceptor extends SaveChangesInterceptor so it can react to data changes before EF commits them. It lets you register cached query keys per table and then automatically clears those entries when EF Core detects updates to the corresponding tables.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Caching.Memory;
using System.Data.Common;
using System.Security.Cryptography;
using System.Text;

namespace Interceptors.Interceptors;

public class CacheInvalidationInterceptor : SaveChangesInterceptor
{
    private readonly IMemoryCache _cache;
    private readonly HashSet<string> _cachedQueries;
    public int CacheInvalidations { get; private set; }

    public CacheInvalidationInterceptor(IMemoryCache cache)
    {
        _cache = cache;
        _cachedQueries = new HashSet<string>();
    }

    // Track SELECT queries for caching
    public void TrackQuery(string tableName, string queryKey)
    {
        var cacheKey = $"query:{tableName}:{queryKey}";

        if (!_cache.TryGetValue(cacheKey, out _))
        {
            Console.WriteLine($"[Cache] Caching query for table '{tableName}'");
            _cache.Set(cacheKey, DateTime.UtcNow, TimeSpan.FromMinutes(10));
            _cachedQueries.Add(cacheKey);
        }
        else
        {
            Console.WriteLine($"[Cache] Query result from cache for table '{tableName}'");
        }
    }

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

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        InvalidateCacheForChanges(eventData.Context);
        return new ValueTask<InterceptionResult<int>>(result);
    }

    private void InvalidateCacheForChanges(DbContext? context)
    {
        if (context == null) return;

        var entries = context.ChangeTracker.Entries();
        var affectedTables = new HashSet<string>();

        foreach (var entry in entries)
        {
            if (entry.State == EntityState.Added ||
                entry.State == EntityState.Modified ||
                entry.State == EntityState.Deleted)
            {
                var tableName = entry.Metadata.GetTableName();
                if (tableName != null)
                {
                    affectedTables.Add(tableName);
                }
            }
        }

        if (affectedTables.Count > 0)
        {
            Console.WriteLine("\n[Cache Invalidation] Data changes detected!");

            foreach (var table in affectedTables)
            {
                Console.WriteLine($"  Table affected: {table}");
                InvalidateCacheForTable(table);
            }
        }
    }

    private void InvalidateCacheForTable(string tableName)
    {
        var keysToRemove = _cachedQueries
            .Where(key => key.Contains($"query:{tableName}:"))
            .ToList();

        foreach (var key in keysToRemove)
        {
            _cache.Remove(key);
            _cachedQueries.Remove(key);
            CacheInvalidations++;
            Console.WriteLine($"  Invalidated cache: {key}");
        }

        if (keysToRemove.Count == 0)
        {
            Console.WriteLine($"  No cached queries to invalidate for table '{tableName}'");
        }
    }

    public void PrintStatistics()
    {
        Console.WriteLine("\n[Cache Statistics]");
        Console.WriteLine($"  Cached Queries: {_cachedQueries.Count}");
        Console.WriteLine($"  Cache Invalidations: {CacheInvalidations}");
    }
}

What's happening here:

  • TrackQuery() records cached query keys grouped by table name.
  • During SaveChanges, the interceptor inspects all tracked entities.
  • Any insert, update, or delete marks the corresponding table as affected.
  • Cached queries tied to those tables are invalidated.
  • Invalidations are counted for reporting.
  • This ensures caching remains consistent when data changes.

Usage

When you run the application, you'll see:

  • Queries being cached.
  • Cache hits for repeated queries.
  • Automatic invalidation when inserts/updates/deletes occur.
  • The cache is being rebuilt after invalidation.
  • Final statistics showing how many invalidations happened.
Applying cache invalidation with EF Core interceptors

This pattern can serve as a foundation for more robust systems, such as integrating invalidation with Redis, SQL dependency tracking, or domain events.

Final word

EF Core interceptors let you move repeated logic, such as logging, auditing, caching, and soft deletes, out of your application code and into EF Core itself. Using the Sakila Actor examples, you've seen how these behaviors can run automatically across your entire data layer without changing your queries, repositories, or application workflow. And because EF Core can run multiple interceptors at the same time, you can combine these patterns to keep your data layer consistent and predictable.

From here, the path forward is clear. Consider introducing structured logging, applying distributed caching where it provides real value, and refining your audit or soft-delete fields to match your project needs. It's also worth testing how these behaviors hold up under real-world load and concurrency.

With these pieces in place, interceptors give you a straightforward and dependable way to guide and standardize how EF Core interacts with your database.

Connect to data effortlessly in .NET

Streamline your .NET projects with feature-rich ADO.NET providers