TL;DR

  • In multi-tenant SaaS, generic audit logging can easily leak data between tenants. This is a security and compliance nightmare.
  • Overriding DbContext.SaveChanges() is a common but clunky solution that tightly couples auditing logic to your data context.
  • EF Core Interceptors provide a clean, decoupled way to hook into the save process and add per-tenant audit logs automatically.
  • The solution involves creating a SaveChangesInterceptor, grabbing the current TenantId from a scoped service, and logging entity changes before they hit the database.
  • This pattern is perfect for auditable, compliant SaaS applications but might be overkill for simple, single-server projects.

I once got a panicked call about a critical bug. An admin from “Company A” could see user creation events from “Company B” in their audit trail. It was a classic multi-tenant data bleed, but not in the main application data—it was in the logs. This is one of those sneaky bugs that passes all unit tests but can absolutely destroy trust with your customers and fail a compliance audit.

The root cause? A “global” audit logging service that didn’t properly isolate the TenantId for every single database transaction. We were lucky to catch it early. That painful experience taught me that for any multi-tenant app, audit logging can’t be an afterthought; it has to be architected for strict data isolation from the ground up.

The old way of handling this was often to override SaveChanges() or SaveChangesAsync() directly in the DbContext. It works, but it bloats your DbContext with cross-cutting concerns. Your data context shouldn’t be responsible for auditing, authentication, or anything else. Its job is to manage database sessions. EF Core Interceptors give us a much cleaner way to solve this.

So, How Do We Intercept EF Core?

EF Core Interceptors are hooks that let you intercept, modify, or suppress EF Core operations. Think of them as middleware for your database calls. For our audit log, we’re interested in the SaveChangesInterceptor. It lets us run code just before SaveChanges is executed (SavingChanges) and right after it completes (SavedChanges).

We’ll use the SavingChangesAsync method to inspect the EF Core Change Tracker, figure out what’s being added, updated, or deleted, and write our audit log—all with the correct TenantId attached.

Let’s Build the Per-Tenant AuditInterceptor

The goal is to automatically create an audit record for every entity change, ensuring it’s tagged with the ID of the tenant who made the change.

Step 1: Getting the Current Tenant ID

First, you need a reliable way to get the current tenant’s ID. In most ASP.NET Core apps, this is managed as a scoped service that’s resolved from an HTTP request (like from a JWT claim or a subdomain).

Here’s a simple representation of such a service:

// In a real app, this would be populated by middleware
public interface ITenantProvider
{
    Guid GetTenantId();
}

public class HttpTenantProvider : ITenantProvider
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public HttpTenantProvider(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public Guid GetTenantId()
    {
        // This is a simplified example. In production, you'd have
        // robust logic to extract the tenant ID from a claim or header.
        var tenantIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("tenant_id");
        if (tenantIdClaim != null && Guid.TryParse(tenantIdClaim.Value, out var tenantId))
        {
            return tenantId;
        }

        // Fail-safe. Should not happen in an authenticated request.
        throw new InvalidOperationException("Could not determine Tenant ID.");
    }
}

Step 2: Creating the Interceptor

Now for the main event. We’ll create a new class that inherits from SaveChangesInterceptor. We inject our ITenantProvider to get the tenant context.

using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System.Text.Json;

public class AuditInterceptor : SaveChangesInterceptor
{
    private readonly ITenantProvider _tenantProvider;
    // You'd also inject your logging service or audit DbContext here.

    public AuditInterceptor(ITenantProvider tenantProvider)
    {
        _tenantProvider = tenantProvider;
    }

    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var dbContext = eventData.Context;
        if (dbContext is null)
        {
            return await base.SavingChangesAsync(eventData, result, cancellationToken);
        }

        var tenantId = _tenantProvider.GetTenantId();
        var auditEntries = CreateAuditEntries(dbContext, tenantId);

        if (auditEntries.Any())
        {
            // Here you would save the auditEntries to your audit log table,
            // a different database, or send them to a logging service like Seq or Datadog.
            // For example: _auditLogService.LogEntriesAsync(auditEntries);
            Console.WriteLine($"AUDIT: Tenant '{tenantId}' made {auditEntries.Count} changes.");
        }

        return await base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private List<AuditEntry> CreateAuditEntries(DbContext context, Guid tenantId)
    {
        var auditEntries = new List<AuditEntry>();
        
        // Use ChangeTracker.Entries() to get all changed entities.
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.State == EntityState.Detached || entry.State == EntityState.Unchanged)
                continue;

            var auditEntry = new AuditEntry
            {
                TenantId = tenantId,
                TableName = entry.Metadata.GetTableName() ?? "Unknown",
                ActionType = entry.State.ToString(),
                Timestamp = DateTime.UtcNow,
                Changes = new Dictionary<string, object?>(),
                // EF Core can track primary keys for you.
                PrimaryKey = JsonSerializer.Serialize(entry.Properties.Where(p => p.Metadata.IsPrimaryKey())
                    .ToDictionary(p => p.Metadata.Name, p => p.CurrentValue))
            };

            foreach (var property in entry.Properties)
            {
                if (property.IsTemporary || !property.IsModified) 
                    continue;

                string propertyName = property.Metadata.Name;
                switch (entry.State)
                {
                    case EntityState.Added:
                        auditEntry.Changes[propertyName] = property.CurrentValue;
                        break;
                    case EntityState.Deleted:
                        auditEntry.Changes[propertyName] = property.OriginalValue;
                        break;
                    case EntityState.Modified:
                        if (property.IsModified)
                        {
                            auditEntry.Changes[propertyName] = new { Old = property.OriginalValue, New = property.CurrentValue };
                        }
                        break;
                }
            }
            auditEntries.Add(auditEntry);
        }
        return auditEntries;
    }
}

// A simple DTO for the audit log record
public class AuditEntry
{
    public Guid TenantId { get; set; }
    public string TableName { get; set; }
    public string ActionType { get; set; }
    public DateTime Timestamp { get; set; }
    public string PrimaryKey { get; set; }
    public Dictionary<string, object?> Changes { get; set; } = new();
}

Expert Insight: We’re hooking into SavingChangesAsync, not SavedChangesAsync. Why? Because SavingChangesAsync runs before the transaction is committed. This allows us to capture the changes and, importantly, roll back the whole operation if our audit logging fails. If you log after the save, you risk having a committed data change without a corresponding audit trail, which is a compliance failure.

Step 3: Registering the Interceptor

Finally, wire it all up in your Program.cs. You add the interceptor when you configure your DbContext.

// In Program.cs

// Register your tenant provider and HttpContextAccessor
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, HttpTenantProvider>();

// Register your interceptor as a scoped service
builder.Services.AddScoped<AuditInterceptor>();

builder.Services.AddDbContext<MyApplicationDbContext>((sp, options) =>
{
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
    var interceptor = sp.GetRequiredService<AuditInterceptor>();

    options.UseSqlServer(connectionString)
           .AddInterceptors(interceptor);
});

And boom. Now, every single call to SaveChangesAsync() in your application will automatically pass through this interceptor. It will generate a detailed audit log, correctly stamped with the active tenant’s ID, without you ever having to change a single line in your business logic.

Lesson Learned: This one burned me once in production. We initially had our audit logging logic writing to the same database as the main application data. During a high-load event, the audit logging table started locking, which caused cascading failures in the primary application logic because it was all part of the same transaction. I now strongly recommend writing audit logs to a separate database or a dedicated logging service to completely isolate it from your application’s performance.

Is There a Performance Cost?

Yes, but it’s usually negligible. The interceptor adds a small amount of overhead to each SaveChanges call. The reflection and serialization involved in CreateAuditEntries can add up if you’re modifying hundreds of entities in a single transaction.

In my experience, for typical web requests that modify a handful of entities, the overhead is well under 5ms, which is an acceptable trade-off for bulletproof, compliant audit trails.

Production Tip: Keep your interceptor logic lean. Avoid making blocking network calls or performing heavy computations inside it. If you need to do something slow, like calling an external API, do it asynchronously after the SaveChanges transaction has completed. You can collect the audit entries in the interceptor and process them in a background job.

When to Use This (And When Not To)

This pattern is my go-to for building robust, multi-tenant systems. Here’s my decision framework:

  • Use this pattern when:

    • You are building a multi-tenant SaaS application where data isolation is critical.
    • You need to meet compliance standards like SOC 2, HIPAA, or GDPR that require strict audit trails.
    • You want to keep your business logic clean of cross-cutting concerns like logging.
  • Avoid this pattern when:

    • You’re building a simple, single-tenant application. Overriding SaveChanges in the DbContext is probably fine.
    • Your application is a high-performance data processing service where every microsecond of database transaction time counts. In that case, you might opt for more specialized, out-of-band logging.

Getting auditing and data isolation right is non-negotiable in SaaS. Using EF Core Interceptors is a clean, modern, and reliable way to ensure you have a complete and correctly-scoped audit trail for every change in your system. It’s much better to build this in from the start than to try and fix data leaks during a production incident.

References

Related Posts