Skip to content
Go back

[.NET] Simplify EF Core Lifecycle Management with DKNet.EfCore.Hooks

Managing cross-cutting concerns in Entity Framework Core applications has always been a challenge. How do you cleanly implement auditing, validation, event publishing, or caching without cluttering your DbContext or business logic? Traditional approaches often lead to scattered code, tight coupling, and maintenance headaches. What if there was a better way to handle these concerns systematically?

DKNet.EfCore.Hooks is leveraging a powerful lifecycle interceptors of Entity Framework Core that provides pre and post-save hook, enabling you to implement cross-cutting concerns in a clean, maintainable, and testable manner. It seamlessly integrates with .NET dependency injection and supports full async/await operations, making it perfect for modern cloud-native applications.


Table of Contents

Open Table of Contents

The Challenge with Cross-Cutting Concerns

Consider a typical enterprise application that needs to:

Traditionally, you might handle these concerns directly in your DbContext:

public class AppDbContext : DbContext
{
    private readonly ICurrentUserService _currentUserService;
    private readonly IEventPublisher _eventPublisher;
    private readonly ICacheManager _cacheManager;

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // Auditing logic
        foreach (var entry in ChangeTracker.Entries<IAuditedEntity>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedBy = _currentUserService.UserId;
                entry.Entity.CreatedOn = DateTime.UtcNow;
            }
            else if (entry.State == EntityState.Modified)
            {
                entry.Entity.UpdatedBy = _currentUserService.UserId;
                entry.Entity.UpdatedOn = DateTime.UtcNow;
            }
        }

        // Validation logic
        foreach (var entry in ChangeTracker.Entries())
        {
            ValidateEntity(entry.Entity);
        }

        var result = await base.SaveChangesAsync(cancellationToken);

        // Event publishing logic
        foreach (var entry in ChangeTracker.Entries<IEventEntity>())
        {
            var events = entry.Entity.GetEvents();
            foreach (var evt in events)
            {
                await _eventPublisher.PublishAsync(evt, cancellationToken);
            }
        }

        // Cache invalidation
        await _cacheManager.InvalidateAsync();

        return result;
    }
}

This approach has several critical problems:


What is DKNet.EfCore.Hooks?

DKNet.EfCore.Hooks is a lifecycle hooks system that solves these problems by providing a clean, extensible architecture for Entity Framework Core interceptors. Here’s what makes it special:


Getting Started

Prerequisites

Before using DKNet.EfCore.Hooks, ensure you have:

Installation

Add the NuGet package to your project:

dotnet add package DKNet.EfCore.Hooks

Or add it directly to your .csproj file:

<ItemGroup>
  <PackageReference Include="DKNet.EfCore.Hooks" Version="latest" />
</ItemGroup>

Basic Setup

Configure your DbContext and register hooks in your dependency injection container:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using DKNet.EfCore.Hooks;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Register your hooks
        services.AddHook<AppDbContext, AuditHook>();
        services.AddHook<AppDbContext, EventPublishingHook>();
        services.AddHook<AppDbContext, CacheInvalidationHook>();

        // Configure DbContext with hook interceptor
        services.AddDbContext<AppDbContext>((provider, options) =>
        {
            options.UseSqlServer(connectionString)
                   .AddHookInterceptor<AppDbContext>(provider);
        });
    }
}

That’s it! Your hooks are now registered and will automatically execute during save operations.


Basic Hook Implementation

DKNet.EfCore.Hooks provides three main hook interfaces:

Before Save Hook Example

Let’s implement an auditing hook that automatically tracks creation and modification metadata:

using DKNet.EfCore.Hooks;
using DKNet.EfCore.Extensions.Snapshots;
using Microsoft.EntityFrameworkCore;

public interface IAuditedEntity
{
    string CreatedBy { get; set; }
    DateTimeOffset CreatedOn { get; set; }
    string? UpdatedBy { get; set; }
    DateTimeOffset? UpdatedOn { get; set; }
}

public class AuditHook : IBeforeSaveHookAsync
{
    private readonly ICurrentUserService _currentUserService;
    private readonly ILogger<AuditHook> _logger;

    public AuditHook(ICurrentUserService currentUserService, ILogger<AuditHook> logger)
    {
        _currentUserService = currentUserService;
        _logger = logger;
    }

    public Task RunBeforeSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        var currentUser = _currentUserService.UserId;
        var now = DateTimeOffset.UtcNow;

        foreach (var entry in context.Entries)
        {
            if (entry.Entity is not IAuditedEntity auditedEntity)
                continue;

            switch (entry.State)
            {
                case EntityState.Added:
                    auditedEntity.CreatedBy = currentUser;
                    auditedEntity.CreatedOn = now;
                    _logger.LogInformation("Entity {EntityType} created by {User}",
                        entry.Entity.GetType().Name, currentUser);
                    break;

                case EntityState.Modified:
                    auditedEntity.UpdatedBy = currentUser;
                    auditedEntity.UpdatedOn = now;
                    _logger.LogInformation("Entity {EntityType} updated by {User}",
                        entry.Entity.GetType().Name, currentUser);
                    break;
            }
        }

        return Task.CompletedTask;
    }
}

After Save Hook Example

Here’s a hook that publishes domain events after entities are successfully saved:

public interface IEventEntity
{
    IReadOnlyCollection<IDomainEvent> GetEvents();
    void ClearEvents();
}

public class EventPublishingHook : IAfterSaveHookAsync
{
    private readonly IEventPublisher _eventPublisher;
    private readonly ILogger<EventPublishingHook> _logger;

    public EventPublishingHook(IEventPublisher eventPublisher, ILogger<EventPublishingHook> logger)
    {
        _eventPublisher = eventPublisher;
        _logger = logger;
    }

    public async Task RunAfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        var eventTasks = new List<Task>();

        foreach (var entry in context.Entries)
        {
            if (entry.Entity is not IEventEntity eventEntity)
                continue;

            var events = eventEntity.GetEvents();
            foreach (var domainEvent in events)
            {
                _logger.LogInformation("Publishing event {EventType} for entity {EntityType}",
                    domainEvent.GetType().Name, entry.Entity.GetType().Name);

                eventTasks.Add(_eventPublisher.PublishAsync(domainEvent, cancellationToken));
            }

            eventEntity.ClearEvents();
        }

        // Execute all event publishing concurrently
        await Task.WhenAll(eventTasks);
    }
}

Combined Hook Example

For scenarios where you need both pre and post-save logic, use IHookAsync:

public class ValidationHook : IHookAsync
{
    private readonly IValidator _validator;
    private readonly ILogger<ValidationHook> _logger;
    private readonly List<ValidationResult> _validationResults = new();

    public ValidationHook(IValidator validator, ILogger<ValidationHook> logger)
    {
        _validator = validator;
        _logger = logger;
    }

    public async Task RunBeforeSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        _validationResults.Clear();

        foreach (var entry in context.Entries)
        {
            if (entry.State is not (EntityState.Added or EntityState.Modified))
                continue;

            var result = await _validator.ValidateAsync(entry.Entity, cancellationToken);
            if (!result.IsValid)
            {
                _validationResults.AddRange(result.Errors);
                _logger.LogWarning("Validation failed for {EntityType}: {Errors}",
                    entry.Entity.GetType().Name,
                    string.Join(", ", result.Errors.Select(e => e.ErrorMessage)));
            }
        }

        if (_validationResults.Any())
        {
            throw new ValidationException(_validationResults);
        }
    }

    public Task RunAfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Validation completed successfully for {Count} entities",
            context.Entries.Count());
        return Task.CompletedTask;
    }
}

Understanding the Snapshot Context

The SnapshotContext is a powerful feature that provides comprehensive information about entity changes. It captures the state of entities before and after save operations, allowing you to:

Using Snapshot Context

public class ChangeTrackingHook : IHookAsync
{
    private readonly IChangeHistoryService _historyService;

    public ChangeTrackingHook(IChangeHistoryService historyService)
    {
        _historyService = historyService;
    }

    public Task RunBeforeSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        // Capture state before save
        foreach (var entry in context.Entries.Where(e => e.State == EntityState.Modified))
        {
            var originalValues = entry.OriginalValues;
            var currentValues = entry.CurrentValues;

            foreach (var property in entry.Properties)
            {
                var originalValue = originalValues[property.Name];
                var currentValue = currentValues[property.Name];

                if (!Equals(originalValue, currentValue))
                {
                    Console.WriteLine($"Property {property.Name} changed from {originalValue} to {currentValue}");
                }
            }
        }

        return Task.CompletedTask;
    }

    public async Task RunAfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        // Log changes after successful save
        foreach (var entry in context.Entries)
        {
            await _historyService.RecordChangeAsync(new ChangeRecord
            {
                EntityType = entry.Entity.GetType().Name,
                EntityId = GetEntityId(entry.Entity),
                Operation = entry.State.ToString(),
                Timestamp = DateTimeOffset.UtcNow
            }, cancellationToken);
        }
    }

    private static object? GetEntityId(object entity)
    {
        return entity.GetType().GetProperty("Id")?.GetValue(entity);
    }
}

Real-World Use Cases

Use Case 1: Automatic Change Tracking and Logging

This example demonstrates how to automatically track and log all entity changes for compliance and debugging purposes:

public interface IAuditedEntity
{
    string CreatedBy { get; set; }
    DateTimeOffset CreatedOn { get; set; }
    string? UpdatedBy { get; set; }
    DateTimeOffset? UpdatedOn { get; set; }
}

public class AuditLoggingHook : IHookAsync
{
    private readonly ICurrentUserService _currentUserService;
    private readonly ILogger<AuditLoggingHook> _logger;
    private readonly List<string> _changeLog = new();

    public AuditLoggingHook(ICurrentUserService currentUserService, ILogger<AuditLoggingHook> logger)
    {
        _currentUserService = currentUserService;
        _logger = logger;
    }

    public Task RunBeforeSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        var currentUser = _currentUserService.UserId;
        var now = DateTimeOffset.UtcNow;
        _changeLog.Clear();

        foreach (var entry in context.Entries)
        {
            if (entry.Entity is not IAuditedEntity auditedEntity)
                continue;

            switch (entry.State)
            {
                case EntityState.Added:
                    auditedEntity.CreatedBy = currentUser;
                    auditedEntity.CreatedOn = now;
                    _changeLog.Add($"Created {entry.Entity.GetType().Name}");
                    break;

                case EntityState.Modified:
                    auditedEntity.UpdatedBy = currentUser;
                    auditedEntity.UpdatedOn = now;

                    // Track which properties changed
                    var changedProperties = entry.Properties
                        .Where(p => p.IsModified)
                        .Select(p => p.Metadata.Name)
                        .ToList();

                    if (changedProperties.Any())
                    {
                        _changeLog.Add($"Modified {entry.Entity.GetType().Name}: {string.Join(", ", changedProperties)}");
                    }
                    break;

                case EntityState.Deleted:
                    _changeLog.Add($"Deleted {entry.Entity.GetType().Name}");
                    break;
            }
        }

        return Task.CompletedTask;
    }

    public Task RunAfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        // Log all changes after successful save
        if (_changeLog.Any())
        {
            _logger.LogInformation("User {UserId} made {ChangeCount} changes: {Changes}",
                _currentUserService.UserId,
                _changeLog.Count,
                string.Join("; ", _changeLog));
        }

        return Task.CompletedTask;
    }
}

Use Case 2: Soft Delete with Cache Invalidation

This example shows how to implement soft deletes while automatically invalidating related cache entries:

public interface ISoftDeletable
{
    bool IsDeleted { get; set; }
    DateTimeOffset? DeletedOn { get; set; }
    string? DeletedBy { get; set; }
}

public class SoftDeleteCacheHook : IHookAsync
{
    private readonly ICurrentUserService _currentUserService;
    private readonly IMemoryCache _cache;
    private readonly ILogger<SoftDeleteCacheHook> _logger;
    private readonly List<string> _invalidatedCacheKeys = new();

    public SoftDeleteCacheHook(
        ICurrentUserService currentUserService,
        IMemoryCache cache,
        ILogger<SoftDeleteCacheHook> logger)
    {
        _currentUserService = currentUserService;
        _cache = cache;
        _logger = logger;
    }

    public Task RunBeforeSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        _invalidatedCacheKeys.Clear();

        foreach (var entry in context.Entries)
        {
            // Convert physical deletes to soft deletes
            if (entry.State == EntityState.Deleted && entry.Entity is ISoftDeletable softDeletable)
            {
                entry.State = EntityState.Modified;
                softDeletable.IsDeleted = true;
                softDeletable.DeletedOn = DateTimeOffset.UtcNow;
                softDeletable.DeletedBy = _currentUserService.UserId;

                _logger.LogInformation("Soft deleting {EntityType} by user {UserId}",
                    entry.Entity.GetType().Name, _currentUserService.UserId);
            }

            // Track cache keys to invalidate for any changed entity
            if (entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
            {
                var entityType = entry.Entity.GetType().Name;
                var cacheKey = $"{entityType}_{GetEntityId(entry.Entity)}";
                _invalidatedCacheKeys.Add(cacheKey);
            }
        }

        return Task.CompletedTask;
    }

    public Task RunAfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        // Invalidate cache after successful save
        foreach (var cacheKey in _invalidatedCacheKeys)
        {
            _cache.Remove(cacheKey);
        }

        if (_invalidatedCacheKeys.Any())
        {
            _logger.LogInformation("Invalidated {Count} cache entries after save",
                _invalidatedCacheKeys.Count);
        }

        return Task.CompletedTask;
    }

    private static object? GetEntityId(object entity)
    {
        return entity.GetType().GetProperty("Id")?.GetValue(entity);
    }
}

Registration Example:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Register the hooks
        services.AddHook<AppDbContext, AuditLoggingHook>();
        services.AddHook<AppDbContext, SoftDeleteCacheHook>();

        // Configure DbContext
        services.AddDbContext<AppDbContext>((provider, options) =>
        {
            options.UseSqlServer(connectionString)
                   .UseHooks(provider);
        });

        // Register dependencies
        services.AddScoped<ICurrentUserService, CurrentUserService>();
        services.AddMemoryCache();
    }
}

// Example entity
public class Product : IAuditedEntity, ISoftDeletable
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }

    // IAuditedEntity
    public string CreatedBy { get; set; } = string.Empty;
    public DateTimeOffset CreatedOn { get; set; }
    public string? UpdatedBy { get; set; }
    public DateTimeOffset? UpdatedOn { get; set; }

    // ISoftDeletable
    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedOn { get; set; }
    public string? DeletedBy { get; set; }
}

These examples demonstrate practical, focused use cases for hooks that handle common requirements like auditing, logging, soft deletes, and cache management without unnecessary complexity.


Hook Execution Order and Performance

Execution Flow

Understanding the hook execution flow is crucial for designing your hooks:

  1. Before Save Hooks execute in registration order
  2. DbContext.SaveChanges() is called
  3. After Save Hooks execute in registration order
  4. Changes are committed to the database
┌─────────────────────────────────────────┐
│ Application calls SaveChangesAsync()    │
└────────────────┬────────────────────────┘

         ┌───────▼────────┐
         │ TenantHook     │ (Before Save Hook 1)
         └───────┬────────┘

         ┌───────▼────────┐
         │ AuditHook      │ (Before Save Hook 2)
         └───────┬────────┘

         ┌───────▼────────┐
         │ ValidationHook │ (Before Save Hook 3)
         └───────┬────────┘

         ┌───────▼────────────────────┐
         │ EF Core SaveChanges()      │
         └───────┬────────────────────┘

         ┌───────▼───────────────────┐
         │ EventPublishingHook       │ (After Save Hook 1)
         └───────┬───────────────────┘

         ┌───────▼──────────────────┐
         │ CacheInvalidationHook    │ (After Save Hook 2)
         └───────┬──────────────────┘

         ┌───────▼──────────────────┐
         │ SearchIndexHook          │ (After Save Hook 3)
         └───────┬──────────────────┘

         ┌───────▼──────────────────┐
         │ Transaction Complete     │
         └──────────────────────────┘

Performance Considerations

Do’s:

Don’ts:

Performance Example

public class PerformantHook : IAfterSaveHookAsync
{
    private readonly IMessageBus _messageBus;
    private readonly IMemoryCache _cache;

    public PerformantHook(IMessageBus messageBus, IMemoryCache cache)
    {
        _messageBus = messageBus;
        _cache = cache;
    }

    public async Task RunAfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        // ✅ Good: Concurrent processing
        var tasks = context.Entries
            .Where(e => e.Entity is IPublishable)
            .Select(e => PublishMessageAsync((IPublishable)e.Entity, cancellationToken))
            .ToList();

        await Task.WhenAll(tasks);
    }

    private Task PublishMessageAsync(IPublishable entity, CancellationToken cancellationToken)
    {
        // ✅ Good: Use cached configuration
        var config = _cache.GetOrCreate("PublishConfig", _ => LoadConfig());

        return _messageBus.PublishAsync(entity.ToMessage(), cancellationToken);
    }

    private PublishConfig LoadConfig()
    {
        // Load configuration once and cache
        return new PublishConfig();
    }
}

Best Practices

1. Keep Hooks Focused

Each hook should have a single, well-defined responsibility:

// ✅ Good: Single responsibility
public class AuditHook : IBeforeSaveHookAsync { /* ... */ }
public class EventPublishingHook : IAfterSaveHookAsync { /* ... */ }

// ❌ Avoid: Multiple responsibilities
public class MegaHook : IHookAsync
{
    // Does auditing, validation, event publishing, caching...
}

2. Use Appropriate Hook Types

3. Handle Errors Appropriately

// ✅ Good: Let critical errors abort the save
public class ValidationHook : IBeforeSaveHookAsync
{
    public async Task RunBeforeSaveAsync(SnapshotContext context, CancellationToken ct)
    {
        if (!await IsValid(context))
            throw new ValidationException(); // Abort save
    }
}

// ✅ Good: Don't let non-critical errors abort the save
public class NotificationHook : IAfterSaveHookAsync
{
    public async Task RunAfterSaveAsync(SnapshotContext context, CancellationToken ct)
    {
        try
        {
            await SendNotifications(context, ct);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Notification failed"); // Don't abort
        }
    }
}

4. Optimize for Performance

// ✅ Good: Concurrent processing
public async Task RunAfterSaveAsync(SnapshotContext context, CancellationToken ct)
{
    var tasks = context.Entries.Select(e => ProcessAsync(e, ct));
    await Task.WhenAll(tasks);
}

// ❌ Avoid: Sequential processing
public async Task RunAfterSaveAsync(SnapshotContext context, CancellationToken ct)
{
    foreach (var entry in context.Entries)
    {
        await ProcessAsync(entry, ct); // Slow!
    }
}

5. Use Dependency Injection

Leverage DI for testability and maintainability:

// ✅ Good: Uses DI
public class MyHook : IBeforeSaveHookAsync
{
    private readonly IService _service;

    public MyHook(IService service) => _service = service;
}

// ❌ Avoid: Hard dependencies
public class MyHook : IBeforeSaveHookAsync
{
    private readonly IService _service = new ServiceImplementation();
}

Conclusion

DKNet.EfCore.Hooks revolutionizes how we handle cross-cutting concerns in Entity Framework Core applications by:

Whether you’re building a small API or a large enterprise application with complex cross-cutting concerns, DKNet.EfCore.Hooks provides the infrastructure you need to keep your code clean, maintainable, and testable. The library seamlessly integrates into your existing EF Core applications and scales from simple auditing to complex multi-tenant scenarios with event sourcing and distributed caching.

By adopting DKNet.EfCore.Hooks, you’re not just adding a library—you’re embracing a better way to structure your data access layer that will pay dividends in maintainability, testability, and developer productivity for years to come.


References



Thank You

Thank you for taking the time to read this comprehensive guide! I hope it has inspired you to implement cleaner, more maintainable Entity Framework Core applications. Feel free to explore the source code, contribute to the project, and happy coding! 🌟✨

Steven | GitHub


Share this post on:

Previous Post
[.NET] Simplify Domain Events with DKNet.EfCore.Events
Next Post
[.NET] Streamline Your DTOs with DKNet.EfCore.DtoGenerator