Search

EF Core Interceptors: Audit Log, Soft Delete Tự Động

EF Core Interceptors: Audit Log, Soft Delete Tự Động

Dự án nào cũng có những yêu cầu kiểu cross-cutting: ai tạo record này, tạo lúc nào, sửa lần cuối bao giờ, xóa mềm thay vì xóa thật, ghi lại lịch sử thay đổi. Và gần như dự án nào cũng xử lý bằng cách nhét logic vào từng service method — entity.CreatedAt = DateTime.UtcNow copy-paste ở khắp nơi, entity.IsDeleted = true rải trong mọi Delete handler.

Nó hoạt động. Nhưng đến khi có người quên một chỗ — quên set ModifiedBy, quên check IsDeleted trong query, quên ghi audit log — thì bug xuất hiện mà không ai biết tại sao.

EF Core Interceptors giải quyết vấn đề này ở đúng nơi nó nên được giải quyết: tầng data access, chạy tự động trước mỗi lần SaveChanges, không phụ thuộc vào việc dev có nhớ hay không.

Interceptor là gì?

Interceptor là hook mà EF Core cho phép bạn can thiệp vào pipeline xử lý — trước hoặc sau khi một operation xảy ra. Có nhiều loại interceptor, nhưng phổ biến nhất là SaveChangesInterceptor — chạy mỗi khi SaveChanges() hoặc SaveChangesAsync() được gọi.

Application code gọi db.SaveChangesAsync()
    ↓
SaveChangesInterceptor.SavingChangesAsync()    ← BẠN CAN THIỆP Ở ĐÂY
    ↓
EF Core detect changes, generate SQL
    ↓
SQL thực thi trên database
    ↓
SaveChangesInterceptor.SavedChangesAsync()     ← HOẶC Ở ĐÂY
    ↓
Kết quả trả về application

Bạn có thể modify entity trước khi save (set timestamp, change operation type), hoặc thực hiện side effect sau khi save (dispatch event, ghi log). Quan trọng nhất: code này chạy cho TẤT CẢ SaveChanges call trong toàn bộ application, không cần dev nhớ gọi.

Bước 1: Base entity và interface

Trước khi viết interceptor, cần định nghĩa contract cho entity:

// interface cho audit fields
public interface IAuditable
{
    DateTime CreatedAt { get; set; }
    string CreatedBy { get; set; }
    DateTime? ModifiedAt { get; set; }
    string? ModifiedBy { get; set; }
}

// interface cho soft delete
public interface ISoftDeletable
{
    bool IsDeleted { get; set; }
    DateTime? DeletedAt { get; set; }
    string? DeletedBy { get; set; }
}

// base entity kết hợp cả hai
public abstract class BaseEntity : IAuditable, ISoftDeletable
{
    public long Id { get; set; }

    // audit
    public DateTime CreatedAt { get; set; }
    public string CreatedBy { get; set; } = string.Empty;
    public DateTime? ModifiedAt { get; set; }
    public string? ModifiedBy { get; set; }

    // soft delete
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public string? DeletedBy { get; set; }
}

Dùng interface thay vì chỉ base class vì có thể entity nào đó cần audit nhưng không cần soft delete, hoặc ngược lại. Interceptor sẽ check interface, không phụ thuộc vào inheritance hierarchy.

public class Order : BaseEntity
{
    public string OrderNumber { get; set; } = string.Empty;
    public decimal Total { get; set; }
    public string Status { get; set; } = "Pending";
    public List<OrderItem> Items { get; set; } = new();
}

public class Product : BaseEntity
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string Sku { get; set; } = string.Empty;
}

// entity CHỈ cần audit, KHÔNG cần soft delete
public class AuditLogEntry : IAuditable
{
    public long Id { get; set; }
    public string EntityName { get; set; } = string.Empty;
    public string Action { get; set; } = string.Empty;
    public string Changes { get; set; } = string.Empty;

    public DateTime CreatedAt { get; set; }
    public string CreatedBy { get; set; } = string.Empty;
    public DateTime? ModifiedAt { get; set; }
    public string? ModifiedBy { get; set; }
}

Bước 2: Audit Interceptor

Interceptor này tự động set CreatedAt, CreatedBy, ModifiedAt, ModifiedBy cho mọi entity implement IAuditable:

public class AuditInterceptor : SaveChangesInterceptor
{
    private readonly IHttpContextAccessor _httpContextAccessor;

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

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

        var now = DateTime.UtcNow;
        var userId = GetCurrentUserId();

        foreach (var entry in eventData.Context.ChangeTracker.Entries<IAuditable>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Entity.CreatedAt = now;
                    entry.Entity.CreatedBy = userId;
                    break;

                case EntityState.Modified:
                    entry.Entity.ModifiedAt = now;
                    entry.Entity.ModifiedBy = userId;
                    // đảm bảo CreatedAt/CreatedBy không bị overwrite
                    entry.Property(nameof(IAuditable.CreatedAt)).IsModified = false;
                    entry.Property(nameof(IAuditable.CreatedBy)).IsModified = false;
                    break;
            }
        }

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

    private string GetCurrentUserId()
    {
        var user = _httpContextAccessor.HttpContext?.User;

        return user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
            ?? user?.FindFirst("sub")?.Value
            ?? "system";
    }
}

Dòng entry.Property(nameof(IAuditable.CreatedAt)).IsModified = false rất quan trọng. Không có nó, khi bạn update một entity, EF Core sẽ coi tất cả property là modified (nếu dùng auto-detect changes) — kể cả CreatedAtCreatedBy. Kết quả là audit field bị ghi đè bằng giá trị hiện tại trên entity thay vì giữ nguyên giá trị gốc. Đây là bug mà mình mất cả ngày mới tìm ra lần đầu gặp.

GetCurrentUserId() lấy user từ HTTP context. Fallback về "system" cho background job hoặc migration — những nơi không có HTTP request.

Bước 3: Soft Delete Interceptor

Thay vì xóa thật record khỏi database, interceptor này convert DELETE thành UPDATE — set IsDeleted = true:

public class SoftDeleteInterceptor : SaveChangesInterceptor
{
    private readonly IHttpContextAccessor _httpContextAccessor;

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

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

        var now = DateTime.UtcNow;
        var userId = _httpContextAccessor.HttpContext?.User
            ?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";

        foreach (var entry in eventData.Context.ChangeTracker.Entries<ISoftDeletable>())
        {
            if (entry.State != EntityState.Deleted) continue;

            // chuyển từ DELETE sang UPDATE
            entry.State = EntityState.Modified;

            entry.Entity.IsDeleted = true;
            entry.Entity.DeletedAt = now;
            entry.Entity.DeletedBy = userId;
        }

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

Toàn bộ logic nằm trong ba dòng cốt lõi: entry.State = EntityState.Modified — nói EF Core "đừng DELETE, hãy UPDATE". Sau đó set IsDeleted, DeletedAt, DeletedBy. EF Core sẽ generate UPDATE ... SET IsDeleted = 1, DeletedAt = ... thay vì DELETE FROM ....

Application code không cần thay đổi gì:

// code vẫn gọi Remove bình thường
db.Orders.Remove(order);
await db.SaveChangesAsync();

// nhưng SQL thực thi sẽ là UPDATE, không phải DELETE

Bước 4: Global Query Filter — auto-exclude deleted records

Soft delete vô nghĩa nếu query vẫn trả về record đã xóa. Thay vì thêm .Where(x => !x.IsDeleted) vào mọi query, dùng global query filter:

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<AuditLogEntry> AuditLogs => Set<AuditLogEntry>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // tự động apply filter cho MỌI entity implement ISoftDeletable
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType)) continue;

            var parameter = Expression.Parameter(entityType.ClrType, "e");
            var property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted));
            var condition = Expression.Equal(property, Expression.Constant(false));
            var lambda = Expression.Lambda(condition, parameter);

            modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
        }
    }
}

Dùng Expression API thay vì set filter cho từng entity — khi bạn thêm entity mới implement ISoftDeletable, filter tự động apply mà không cần chỉnh DbContext.

Giờ mọi query bình thường sẽ tự động exclude deleted records:

// SQL: SELECT * FROM Orders WHERE IsDeleted = 0
var orders = await db.Orders.ToListAsync();

// khi CẦN lấy cả deleted — admin panel, restore feature
var allOrders = await db.Orders.IgnoreQueryFilters().ToListAsync();

IgnoreQueryFilters() là escape hatch khi bạn thực sự cần thấy deleted records — admin dashboard, data recovery, reporting.

Bước 5: Audit Log chi tiết — ghi lại mọi thay đổi

Audit interceptor ở trên chỉ set timestamp. Nếu bạn cần ghi lại AI ĐÃ THAY ĐỔI CÁI GÌ — kiểu "user A đổi status từ Pending sang Completed lúc 14:30" — cần một interceptor phức tạp hơn:

public class DetailedAuditInterceptor : SaveChangesInterceptor
{
    private readonly IHttpContextAccessor _httpContextAccessor;

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

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

        var auditEntries = CollectAuditEntries(eventData.Context);
        if (auditEntries.Count > 0)
        {
            eventData.Context.Set<AuditLogEntry>().AddRange(auditEntries);
        }

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

    private List<AuditLogEntry> CollectAuditEntries(DbContext context)
    {
        var userId = _httpContextAccessor.HttpContext?.User
            ?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
        var now = DateTime.UtcNow;
        var entries = new List<AuditLogEntry>();

        foreach (var entry in context.ChangeTracker.Entries())
        {
            // bỏ qua audit log entity — tránh vòng lặp vô hạn
            if (entry.Entity is AuditLogEntry) continue;

            // chỉ track Added, Modified, Deleted
            if (entry.State is EntityState.Detached or EntityState.Unchanged) continue;

            var changes = new Dictionary<string, object?>();

            switch (entry.State)
            {
                case EntityState.Added:
                    foreach (var prop in entry.Properties)
                    {
                        changes[prop.Metadata.Name] = prop.CurrentValue;
                    }
                    break;

                case EntityState.Modified:
                    foreach (var prop in entry.Properties.Where(p => p.IsModified))
                    {
                        changes[prop.Metadata.Name] = new
                        {
                            From = prop.OriginalValue,
                            To = prop.CurrentValue
                        };
                    }
                    break;

                case EntityState.Deleted:
                    foreach (var prop in entry.Properties)
                    {
                        changes[prop.Metadata.Name] = prop.OriginalValue;
                    }
                    break;
            }

            if (changes.Count == 0) continue;

            entries.Add(new AuditLogEntry
            {
                EntityName = entry.Entity.GetType().Name,
                Action = entry.State.ToString(),
                Changes = JsonSerializer.Serialize(changes),
                CreatedAt = now,
                CreatedBy = userId,
            });
        }

        return entries;
    }
}

Check if (entry.Entity is AuditLogEntry) continue cực kỳ quan trọng — nếu không có, interceptor sẽ tạo audit log cho audit log, rồi tạo audit log cho audit log đó, rồi tiếp... vòng lặp vô hạn, stack overflow.

Kết quả trong bảng AuditLogs:

{
  "EntityName": "Order",
  "Action": "Modified",
  "Changes": {
    "Status": { "From": "Pending", "To": "Completed" },
    "ModifiedAt": { "From": null, "To": "2026-03-09T10:30:00Z" }
  },
  "CreatedBy": "user-123",
  "CreatedAt": "2026-03-09T10:30:00Z"
}

Ai đổi gì, lúc nào, từ giá trị nào sang giá trị nào — đầy đủ.

Bước 6: Đăng ký interceptor

// Program.cs
builder.Services.AddHttpContextAccessor();

builder.Services.AddSingleton<AuditInterceptor>();
builder.Services.AddSingleton<SoftDeleteInterceptor>();
builder.Services.AddSingleton<DetailedAuditInterceptor>();

builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default"));

    // thêm interceptors — THỨ TỰ QUAN TRỌNG
    options.AddInterceptors(
        sp.GetRequiredService<SoftDeleteInterceptor>(),    // chạy trước: convert delete → update
        sp.GetRequiredService<AuditInterceptor>(),         // set audit fields
        sp.GetRequiredService<DetailedAuditInterceptor>()  // ghi chi tiết changes
    );
});

Thứ tự interceptor quan trọng: SoftDeleteInterceptor phải chạy trước AuditInterceptor. Tại sao? Vì soft delete chuyển entity từ Deleted sang Modified — sau đó audit interceptor sẽ thấy state Modified và set ModifiedAt/ModifiedBy đúng cách. Nếu đảo ngược, audit interceptor thấy state Deleted và không set modified fields.

Bonus: Domain Event Interceptor

Ngoài audit và soft delete, interceptor rất mạnh cho việc dispatch domain events — pattern phổ biến trong DDD:

// base entity với domain events
public abstract class BaseEntity : IAuditable, ISoftDeletable
{
    // ... properties như trên

    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents;

    public void AddDomainEvent(IDomainEvent e) => _domainEvents.Add(e);
    public void ClearDomainEvents() => _domainEvents.Clear();
}

public interface IDomainEvent
{
    DateTime OccurredAt { get; }
}

public record OrderCompletedEvent(long OrderId, decimal Total) : IDomainEvent
{
    public DateTime OccurredAt { get; } = DateTime.UtcNow;
}

Interceptor dispatch events SAU khi save thành công:

public class DomainEventInterceptor : SaveChangesInterceptor
{
    private readonly IMediator _mediator;

    public DomainEventInterceptor(IMediator mediator)
    {
        _mediator = mediator;
    }

    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is null) return result;

        // collect events TRƯỚC khi clear
        var events = eventData.Context.ChangeTracker.Entries<BaseEntity>()
            .SelectMany(e => e.Entity.DomainEvents)
            .ToList();

        // clear events trên entities
        foreach (var entry in eventData.Context.ChangeTracker.Entries<BaseEntity>())
        {
            entry.Entity.ClearDomainEvents();
        }

        // dispatch SAU khi save — đảm bảo data đã persist
        foreach (var domainEvent in events)
        {
            await _mediator.Publish(domainEvent, cancellationToken);
        }

        return result;
    }
}

Lưu ý dùng SavedChangesAsync (quá khứ — ĐÃ save) chứ không phải SavingChangesAsync (đang save). Events chỉ nên dispatch sau khi data đã persist thành công vào database. Nếu dispatch trước mà save fail, event handler đã chạy rồi — inconsistency.

Sử dụng trong business logic:

public class CompleteOrderHandler
{
    private readonly AppDbContext _db;

    public async Task Handle(long orderId)
    {
        var order = await _db.Orders.FindAsync(orderId);
        order!.Status = "Completed";

        // thêm domain event — sẽ được dispatch SAU SaveChanges
        order.AddDomainEvent(new OrderCompletedEvent(order.Id, order.Total));

        await _db.SaveChangesAsync();
        // interceptor tự dispatch OrderCompletedEvent
        // → handler gửi email, cập nhật inventory, v.v.
    }
}

Restore soft deleted record

Tính năng thường bị quên — admin cần undelete:

public class RestoreService
{
    private readonly AppDbContext _db;

    public async Task RestoreOrder(long orderId)
    {
        // IgnoreQueryFilters để thấy deleted records
        var order = await _db.Orders
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(o => o.Id == orderId && o.IsDeleted);

        if (order is null) throw new KeyNotFoundException("Order not found or not deleted");

        order.IsDeleted = false;
        order.DeletedAt = null;
        order.DeletedBy = null;

        await _db.SaveChangesAsync();
        // AuditInterceptor tự set ModifiedAt/ModifiedBy
    }
}

Cascade soft delete

Khi soft delete Order, bạn có muốn soft delete luôn OrderItems không? EF Core cascade delete chỉ hoạt động với hard delete. Soft delete cascade phải tự xử lý:

public class CascadeSoftDeleteInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is null) return base.SavingChangesAsync(eventData, result, cancellationToken);

        var softDeletedEntries = eventData.Context.ChangeTracker
            .Entries<ISoftDeletable>()
            .Where(e => e.State == EntityState.Modified && e.Entity.IsDeleted)
            .Where(e => e.Property(nameof(ISoftDeletable.IsDeleted)).IsModified)
            .ToList();

        foreach (var entry in softDeletedEntries)
        {
            // tìm navigation properties
            foreach (var nav in entry.Navigations)
            {
                if (nav.CurrentValue is null) continue;

                if (nav.CurrentValue is IEnumerable<ISoftDeletable> children)
                {
                    foreach (var child in children)
                    {
                        child.IsDeleted = true;
                        child.DeletedAt = DateTime.UtcNow;
                    }
                }
                else if (nav.CurrentValue is ISoftDeletable child)
                {
                    child.IsDeleted = true;
                    child.DeletedAt = DateTime.UtcNow;
                }
            }
        }

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

Cần load navigation properties trước khi delete — nếu không, nav.CurrentValue sẽ null:

var order = await db.Orders
    .Include(o => o.Items)  // phải include
    .FirstOrDefaultAsync(o => o.Id == orderId);

db.Orders.Remove(order);
await db.SaveChangesAsync();
// SoftDeleteInterceptor convert delete → update
// CascadeSoftDeleteInterceptor soft delete Items

Những lỗi hay gặp

Interceptor chạy trong background job không có HttpContext. IHttpContextAccessor trả về null khi không có HTTP request. Fallback ?? "system" trong GetCurrentUserId() xử lý case này. Nhưng nếu background job chạy dưới tên user cụ thể, bạn cần truyền user info qua cách khác — ví dụ ambient context hoặc scope variable.

Performance với bảng lớn. ChangeTracker.Entries() scan tất cả tracked entities mỗi lần SaveChanges. Với batch insert 10.000 record, nó sẽ loop qua 10.000 entry. Giải pháp: nếu batch insert không cần audit, dùng ExecuteUpdate/ExecuteDelete (EF Core 7+) — chúng bypass interceptor hoàn toàn.

Audit log table phình to. Mỗi SaveChanges có thể tạo nhiều audit entries. Bảng AuditLogs sẽ lớn rất nhanh. Partition theo tháng (như bài PostgreSQL Partitioning), hoặc archive sang cold storage định kỳ.

Query filter ảnh hưởng JOIN. Khi bạn include navigation property đã bị soft delete, global filter sẽ exclude nó. OrderItems, xóa mềm 1 item → query Include(o => o.Items) sẽ không thấy item đó. Đôi khi đây là behavior mong muốn, đôi khi không — cần test kỹ.

Test integration với interceptor. Trong unit test, bạn có thể muốn tắt interceptor để test business logic thuần. Dùng DbContextOptionsBuilder riêng cho test, không add interceptor.

Tổng kết

Interceptor là một trong những feature mạnh nhất của EF Core mà ít người tận dụng hết. Thay vì rải logic audit, soft delete, event dispatching khắp codebase, bạn tập trung tất cả vào một nơi — chạy tự động, không phụ thuộc vào dev có nhớ hay không.

Ba interceptor mình dùng trong mọi project: AuditInterceptor để biết ai thay đổi gì khi nào, SoftDeleteInterceptor để không bao giờ mất data, và DomainEventInterceptor để decouple side effects khỏi business logic. Kết hợp với global query filter cho soft delete, bạn có một hệ thống data access robust mà application code gần như không cần quan tâm đến những cross-cutting concern này.

Copy template ở trên, chỉnh interface theo entity của project bạn, đăng ký vào DbContext là xong. Phần khó nhất không phải code — mà là đảm bảo thứ tự interceptor đúng và test đủ edge case.

Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy