Skip to content

EF Core Integration

RzR edited this page Apr 20, 2026 · 1 revision

EF Core Integration

DataVigil hooks into EF Core through interceptors. This page covers how the interceptors work, how to mark entities for auditing, and how to control what gets tracked.


How interception works

When you call AddAuditInterceptors(sp) on your DbContextOptionsBuilder, two interceptors get registered:

  1. AuditSaveChangesInterceptor - fires on every SaveChanges / SaveChangesAsync. This is the main CUD (create, update, delete) auditing path.
  2. AuditCommandInterceptor - fires after SELECT queries complete (relational databases only). This is opt-in and only active when IncludeReads() is enabled.

Both interceptors have safety guards built in:

  • They skip AuditDbContextBase descendants, so writing audit records doesn't trigger more audit records (infinite recursion prevention).
  • They only process DbContext types that were registered via Intercept<TContext>().
  • They only look at entities that implement IAuditable.

Marking entities for auditing

Simple marker - IAuditable

The most basic approach. Every create, update, and delete gets logged:

using RzR.DataVigil.Abstractions.Contracts;

public class Order : IAuditable
{
    public int Id { get; set; }
    public string Status { get; set; }
    public decimal TotalAmount { get; set; }
}

Granular control with IAuditableEntity

When you want to pick which actions to audit or exclude certain properties:

using RzR.DataVigil.Abstractions.Contracts;
using RzR.DataVigil.Abstractions.Enums;

public class SensitiveDocument : IAuditableEntity
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string InternalNotes { get; set; }
    public string Body { get; set; }

    // Only audit Create and Delete, skip Update
    public bool ShouldAudit(AuditAction action)
        => action != AuditAction.Update;

    // Keep InternalNotes out of audit records
    public IEnumerable<string> GetExcludedFields()
        => new[] { nameof(InternalNotes) };
}

IAuditableEntity extends IAuditable, so you get the marker interface for free. The interceptor calls ShouldAudit() for each detected change and GetExcludedFields() when building the audit entry.


Entity exclusions

There are multiple levels where you can exclude entities from auditing.

Context-level (IAuditableContext)

Implement this on your DbContext to exclude entire entity types from auditing within that specific context:

using RzR.DataVigil.Abstractions.Contracts;

public class AppDbContext : DbContext, IAuditableContext
{
    public IEnumerable<Type> GetExcludedEntityTypes()
        => new[] { typeof(AuditLog), typeof(MigrationHistory) };
}

Global exclusions (options)

Exclude types across all contexts:

services.AddAuditTrail(options =>
{
    options.Exclude<HealthCheckResult>();
    options.Exclude<TempData>();
});

Priority order

The interceptor checks in this order and skips the entity if any check says "no":

  1. Is the entity type in GlobalExclusions? -> skip
  2. Does the DbContext implement IAuditableContext and list this type? -> skip
  3. Does the entity implement IAuditableEntity and ShouldAudit() returns false? -> skip
  4. Otherwise -> audit it

What gets captured

The ChangeTrackerEntryBuilder handles the mapping from EF's EntityEntry to an AuditEntry:

Entity State OldValue NewValue Properties captured
Added null current value All properties
Modified original value current value Only properties where old ≠ new
Deleted original value null All properties

Primary keys are extracted automatically (works with single and composite keys). The entity ID in the AuditEntry is the stringified PK - for composite keys, values are joined with _.

Fields listed in IAuditableEntity.GetExcludedFields() are removed after building, so they never show up in the audit entry.


Read auditing

Read auditing is off by default. Turn it on when you need to track who queried what data.

Enable it

options.EfCore
    .Intercept<AppDbContext>()
    .IncludeReads()
    .IncludeReadProperties();        // optional: also log column names

Relational databases (SQL Server, PostgreSQL)

The AuditCommandInterceptor fires after ReaderExecutedAsync. It:

  1. Parses table names from the SQL (FROM and JOIN clauses, regex-based)
  2. Maps table names back to entity types via EF Core metadata
  3. Skips non-IAuditable entities
  4. Extracts entity IDs from WHERE clause parameters when possible
  5. Optionally captures selected column names
  6. Adds read entries to the scoped AuditReadCollector

MongoDB

MongoDB uses a different mechanism because queries don't go through DbCommand. Instead, AuditMaterializationInterceptor (an IMaterializationInterceptor) fires every time EF Core materializes an entity. If it's IAuditable, a read entry gets collected.

Flushing read entries

Read entries accumulate during a request. In ASP.NET Core, call UseAuditReadFlush() in your middleware pipeline:

app.UseRouting();
app.UseAuditReadFlush();    // flushes after downstream completes
app.MapControllers();

The middleware resolves AuditReadCollector, wraps all collected entries into an AuditTransaction, and sends it through AuditPipeline.ProcessAsync().

Manual read logging

If you want to explicitly log a read (bypassing the interceptor), use AuditReadService:

var readService = serviceProvider.GetRequiredService<AuditReadService>();
var order = await dbContext.Orders.FindAsync(42);

await readService.LogReadAsync<Order>(dbContext, order);

// Or for collections:
var orders = await dbContext.Orders.Where(o => o.Status == "Active").ToListAsync();
await readService.LogReadAsync<Order>(dbContext, orders);

Registering interceptors on your DbContext

The AddAuditInterceptors extension method wires both interceptors into your DbContextOptionsBuilder:

builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
{
    opts.UseSqlServer(connectionString);
    opts.AddAuditInterceptors(sp);   // adds SaveChanges + Command interceptors
});

For MongoDB, there's a separate extension for the materialization interceptor:

builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
{
    opts.UseMongoDB(connectionString, databaseName);
    opts.AddAuditInterceptors(sp);       // SaveChanges interceptor
    opts.AddAuditReadInterceptor(sp);    // Materialization interceptor for reads
});

Multiple DbContexts

You can intercept more than one DbContext. Just call Intercept<T>() for each:

options.EfCore
    .Intercept<OrderDbContext>()
    .Intercept<InventoryDbContext>();

Each context still needs AddAuditInterceptors(sp) in its own registration. The interceptor checks at runtime whether the context type is in the list and skips those that aren't registered.

Clone this wiki locally