Lifecycle hooks for Entity Framework Core operations that provide extensible interception points for database operations, enabling cross-cutting concerns and custom logic execution during the EF Core lifecycle while supporting Domain-Driven Design (DDD) and Onion Architecture principles.
DKNet.EfCore.Hooks provides a flexible hook system for Entity Framework Core that allows you to intercept and extend database operations at various points in the EF Core lifecycle. This enables the implementation of cross-cutting concerns such as auditing, validation, performance monitoring, and custom business logic without cluttering your domain entities or application services.
DKNet.EfCore.Hooks implements Cross-Cutting Concerns that span multiple layers of the Onion Architecture, providing infrastructure services that support all layers without creating dependencies:
┌─────────────────────────────────────────────────────────────────┐
│                    🌐 Presentation Layer                        │
│                   (Controllers, API Endpoints)                  │
│                                                                 │
│  Benefits from: Audit logs, performance metrics, validation    │
└─────────────────────────┬───────────────────────────────────────┘
                          │
┌─────────────────────────┴───────────────────────────────────────┐
│                   🎯 Application Layer                          │
│              (Use Cases, Application Services)                  │
│                                                                 │
│  Benefits from: Transaction hooks, validation, error handling  │
└─────────────────────────┬───────────────────────────────────────┘
                          │
┌─────────────────────────┴───────────────────────────────────────┐
│                    💼 Domain Layer                             │
│           (Entities, Aggregates, Domain Services)              │
│                                                                 │
│  Benefits from: Domain rule enforcement, business validation   │
│  🏷️ Remains unaware of hook implementations                    │
└─────────────────────────┬───────────────────────────────────────┘
                          │
┌─────────────────────────┴───────────────────────────────────────┐
│                 🗄️ Infrastructure Layer                        │
│                  (Hooks, Persistence, Cross-cutting)           │
│                                                                 │
│  🎯 Hook Implementations:                                       │
│  📊 Performance Monitoring Hooks                               │
│  📝 Audit Logging Hooks                                        │
│  ✅ Validation Hooks                                           │
│  🔒 Security Hooks                                             │
│  🔄 EF Core Integration                                         │
└─────────────────────────────────────────────────────────────────┘
dotnet add package DKNet.EfCore.Hooks
dotnet add package DKNet.EfCore.Abstractions
using DKNet.EfCore.Hooks;
using DKNet.EfCore.Abstractions;
public class AuditLoggingHook : IHook
{
    private readonly ILogger<AuditLoggingHook> _logger;
    private readonly ICurrentUserService _currentUserService;
    
    public AuditLoggingHook(ILogger<AuditLoggingHook> logger, ICurrentUserService currentUserService)
    {
        _logger = logger;
        _currentUserService = currentUserService;
    }
    
    public async Task OnPreSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        var auditEntries = new List<AuditEntry>();
        var currentUser = _currentUserService.GetCurrentUser();
        
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.Entity is IAuditableEntity auditableEntity)
            {
                switch (entry.State)
                {
                    case EntityState.Added:
                        auditableEntity.CreatedBy = currentUser?.Id;
                        auditableEntity.CreatedAt = DateTime.UtcNow;
                        auditableEntity.UpdatedBy = currentUser?.Id;
                        auditableEntity.UpdatedAt = DateTime.UtcNow;
                        
                        auditEntries.Add(new AuditEntry
                        {
                            EntityName = entry.Entity.GetType().Name,
                            Action = AuditAction.Create,
                            EntityId = GetEntityId(entry.Entity),
                            UserId = currentUser?.Id,
                            Timestamp = DateTime.UtcNow,
                            Changes = GetPropertyChanges(entry)
                        });
                        break;
                        
                    case EntityState.Modified:
                        auditableEntity.UpdatedBy = currentUser?.Id;
                        auditableEntity.UpdatedAt = DateTime.UtcNow;
                        
                        auditEntries.Add(new AuditEntry
                        {
                            EntityName = entry.Entity.GetType().Name,
                            Action = AuditAction.Update,
                            EntityId = GetEntityId(entry.Entity),
                            UserId = currentUser?.Id,
                            Timestamp = DateTime.UtcNow,
                            Changes = GetPropertyChanges(entry)
                        });
                        break;
                        
                    case EntityState.Deleted:
                        auditEntries.Add(new AuditEntry
                        {
                            EntityName = entry.Entity.GetType().Name,
                            Action = AuditAction.Delete,
                            EntityId = GetEntityId(entry.Entity),
                            UserId = currentUser?.Id,
                            Timestamp = DateTime.UtcNow
                        });
                        break;
                }
            }
        }
        
        // Store audit entries
        foreach (var auditEntry in auditEntries)
        {
            context.Set<AuditEntry>().Add(auditEntry);
        }
        
        _logger.LogInformation("Audit logging completed for {Count} entities", auditEntries.Count);
    }
    
    public Task OnPostSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        // Post-save audit logic if needed
        return Task.CompletedTask;
    }
    
    private static string GetEntityId(object entity)
    {
        // Extract entity ID using reflection or conventions
        var idProperty = entity.GetType().GetProperty("Id");
        return idProperty?.GetValue(entity)?.ToString() ?? string.Empty;
    }
    
    private static Dictionary<string, object> GetPropertyChanges(EntityEntry entry)
    {
        var changes = new Dictionary<string, object>();
        
        foreach (var property in entry.Properties)
        {
            if (property.IsModified)
            {
                changes[property.Metadata.Name] = new
                {
                    OldValue = property.OriginalValue,
                    NewValue = property.CurrentValue
                };
            }
        }
        
        return changes;
    }
}
public class PerformanceMonitoringHook : IHook
{
    private readonly ILogger<PerformanceMonitoringHook> _logger;
    private readonly IMetricsCollector _metricsCollector;
    private readonly Dictionary<DbContext, Stopwatch> _contextTimers = new();
    
    public PerformanceMonitoringHook(ILogger<PerformanceMonitoringHook> logger, IMetricsCollector metricsCollector)
    {
        _logger = logger;
        _metricsCollector = metricsCollector;
    }
    
    public Task OnPreSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        var stopwatch = Stopwatch.StartNew();
        _contextTimers[context] = stopwatch;
        
        var changeCount = context.ChangeTracker.Entries()
            .Count(e => e.State == EntityState.Added || 
                       e.State == EntityState.Modified || 
                       e.State == EntityState.Deleted);
        
        _logger.LogInformation("Starting SaveChanges operation with {ChangeCount} changes", changeCount);
        
        return Task.CompletedTask;
    }
    
    public async Task OnPostSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        if (_contextTimers.TryGetValue(context, out var stopwatch))
        {
            stopwatch.Stop();
            _contextTimers.Remove(context);
            
            var elapsed = stopwatch.Elapsed;
            var changeCount = context.ChangeTracker.Entries()
                .Count(e => e.State == EntityState.Unchanged);
            
            _logger.LogInformation("SaveChanges completed in {ElapsedMs}ms for {ChangeCount} changes", 
                elapsed.TotalMilliseconds, changeCount);
            
            // Collect metrics
            await _metricsCollector.RecordSaveChangesMetricAsync(elapsed, changeCount);
            
            // Warn about slow operations
            if (elapsed.TotalMilliseconds > 1000)
            {
                _logger.LogWarning("Slow SaveChanges operation detected: {ElapsedMs}ms", 
                    elapsed.TotalMilliseconds);
            }
        }
    }
}
public class ValidationHook : IHook
{
    private readonly ILogger<ValidationHook> _logger;
    private readonly IServiceProvider _serviceProvider;
    
    public ValidationHook(ILogger<ValidationHook> logger, IServiceProvider serviceProvider)
    {
        _logger = logger;
        _serviceProvider = serviceProvider;
    }
    
    public async Task OnPreSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        var validationErrors = new List<ValidationError>();
        
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.State == EntityState.Added || entry.State == EntityState.Modified)
            {
                // Standard validation using data annotations
                var validationContext = new ValidationContext(entry.Entity, _serviceProvider, null);
                var validationResults = new List<ValidationResult>();
                
                if (!Validator.TryValidateObject(entry.Entity, validationContext, validationResults, true))
                {
                    foreach (var validationResult in validationResults)
                    {
                        validationErrors.Add(new ValidationError
                        {
                            EntityType = entry.Entity.GetType().Name,
                            PropertyName = validationResult.MemberNames.FirstOrDefault(),
                            ErrorMessage = validationResult.ErrorMessage,
                            AttemptedValue = GetPropertyValue(entry.Entity, validationResult.MemberNames.FirstOrDefault())
                        });
                    }
                }
                
                // Custom business rule validation
                if (entry.Entity is IValidatableEntity validatableEntity)
                {
                    var businessValidationResults = await validatableEntity.ValidateAsync(cancellationToken);
                    foreach (var result in businessValidationResults.Where(r => !r.IsValid))
                    {
                        validationErrors.Add(new ValidationError
                        {
                            EntityType = entry.Entity.GetType().Name,
                            PropertyName = result.PropertyName,
                            ErrorMessage = result.ErrorMessage,
                            AttemptedValue = result.AttemptedValue
                        });
                    }
                }
            }
        }
        
        if (validationErrors.Any())
        {
            _logger.LogWarning("Validation failed for {Count} entities", validationErrors.Count);
            throw new ValidationException("Entity validation failed", validationErrors);
        }
    }
    
    public Task OnPostSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
    
    private static object? GetPropertyValue(object entity, string? propertyName)
    {
        if (string.IsNullOrEmpty(propertyName)) return null;
        
        var property = entity.GetType().GetProperty(propertyName);
        return property?.GetValue(entity);
    }
}
public class SecurityHook : IHook
{
    private readonly ILogger<SecurityHook> _logger;
    private readonly ICurrentUserService _currentUserService;
    private readonly IAuthorizationService _authorizationService;
    
    public SecurityHook(
        ILogger<SecurityHook> logger,
        ICurrentUserService currentUserService,
        IAuthorizationService authorizationService)
    {
        _logger = logger;
        _currentUserService = currentUserService;
        _authorizationService = authorizationService;
    }
    
    public async Task OnPreSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        var currentUser = _currentUserService.GetCurrentUser();
        
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.Entity is ISecurableEntity securableEntity)
            {
                var operation = entry.State switch
                {
                    EntityState.Added => "Create",
                    EntityState.Modified => "Update",
                    EntityState.Deleted => "Delete",
                    _ => null
                };
                
                if (operation != null)
                {
                    var isAuthorized = await _authorizationService.AuthorizeAsync(
                        currentUser,
                        securableEntity,
                        operation);
                    
                    if (!isAuthorized)
                    {
                        _logger.LogWarning("User {UserId} attempted unauthorized {Operation} on {EntityType} {EntityId}",
                            currentUser?.Id, operation, entry.Entity.GetType().Name, securableEntity.Id);
                        
                        throw new UnauthorizedAccessException(
                            $"User is not authorized to {operation.ToLower()} this {entry.Entity.GetType().Name}");
                    }
                }
            }
        }
    }
    
    public Task OnPostSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
}
public class ApplicationDbContext : DbContext
{
    private readonly IEnumerable<IHook> _hooks;
    
    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options,
        IEnumerable<IHook> hooks) : base(options)
    {
        _hooks = hooks;
    }
    
    public DbSet<Customer> Customers { get; set; }
    public DbSet<Order> Orders { get; set; }
    public DbSet<AuditEntry> AuditEntries { get; set; }
    
    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // Execute pre-save hooks
        foreach (var hook in _hooks)
        {
            await hook.OnPreSaveChangesAsync(this, cancellationToken);
        }
        
        try
        {
            // Save changes to database
            var result = await base.SaveChangesAsync(cancellationToken);
            
            // Execute post-save hooks
            foreach (var hook in _hooks)
            {
                await hook.OnPostSaveChangesAsync(this, cancellationToken);
            }
            
            return result;
        }
        catch (Exception ex)
        {
            // Execute error hooks if needed
            foreach (var hook in _hooks.OfType<IErrorHook>())
            {
                await hook.OnErrorAsync(this, ex, cancellationToken);
            }
            throw;
        }
    }
}
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddEfCoreHooks(this IServiceCollection services)
    {
        // Register hooks
        services.AddScoped<IHook, AuditLoggingHook>();
        services.AddScoped<IHook, PerformanceMonitoringHook>();
        services.AddScoped<IHook, ValidationHook>();
        services.AddScoped<IHook, SecurityHook>();
        
        // Register supporting services
        services.AddScoped<ICurrentUserService, CurrentUserService>();
        services.AddScoped<IAuthorizationService, AuthorizationService>();
        services.AddScoped<IMetricsCollector, MetricsCollector>();
        
        return services;
    }
}
// In Program.cs or Startup.cs
services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
services.AddEfCoreHooks();
public class ConditionalAuditHook : IHook
{
    private readonly IConfiguration _configuration;
    
    public ConditionalAuditHook(IConfiguration configuration)
    {
        _configuration = configuration;
    }
    
    public async Task OnPreSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        var auditEnabled = _configuration.GetValue<bool>("Auditing:Enabled");
        if (!auditEnabled) return;
        
        var sensitiveEntities = context.ChangeTracker.Entries()
            .Where(e => e.Entity.GetType().GetCustomAttribute<SensitiveDataAttribute>() != null)
            .ToList();
        
        if (sensitiveEntities.Any())
        {
            // Special handling for sensitive data
            await ProcessSensitiveDataAuditAsync(sensitiveEntities, cancellationToken);
        }
    }
    
    public Task OnPostSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
}
public class SoftDeleteHook : IHook
{
    private readonly ICurrentUserService _currentUserService;
    
    public SoftDeleteHook(ICurrentUserService currentUserService)
    {
        _currentUserService = currentUserService;
    }
    
    public Task OnPreSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        var currentUser = _currentUserService.GetCurrentUser();
        
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.Entity is ISoftDeletableEntity softDeletableEntity && entry.State == EntityState.Deleted)
            {
                // Convert hard delete to soft delete
                entry.State = EntityState.Modified;
                softDeletableEntity.IsDeleted = true;
                softDeletableEntity.DeletedAt = DateTime.UtcNow;
                softDeletableEntity.DeletedBy = currentUser?.Id;
            }
        }
        
        return Task.CompletedTask;
    }
    
    public Task OnPostSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
}
public class EventIntegrationHook : IHook
{
    private readonly IEventPublisher _eventPublisher;
    
    public EventIntegrationHook(IEventPublisher eventPublisher)
    {
        _eventPublisher = eventPublisher;
    }
    
    public Task OnPreSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        // Collect events before saving
        return Task.CompletedTask;
    }
    
    public async Task OnPostSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        // Publish integration events after successful save
        var eventEntities = context.ChangeTracker.Entries()
            .Where(e => e.Entity is IEventEntity && e.State != EntityState.Detached)
            .Select(e => e.Entity as IEventEntity)
            .ToList();
        
        foreach (var eventEntity in eventEntities)
        {
            var events = eventEntity?.GetEvents() ?? Enumerable.Empty<EntityEventItem>();
            foreach (var eventItem in events)
            {
                await _eventPublisher.PublishAsync(eventItem.EventData, cancellationToken);
            }
            
            eventEntity?.ClearEvents();
        }
    }
}
// Good: Focused single responsibility
public class AuditLoggingHook : IHook
{
    // Only handles audit logging
}
public class ValidationHook : IHook
{
    // Only handles validation
}
// Avoid: Multiple responsibilities in one hook
public class CompositeHook : IHook
{
    // Handles audit, validation, security, etc. (too many responsibilities)
}
public class ResilientHook : IHook
{
    private readonly ILogger<ResilientHook> _logger;
    
    public async Task OnPreSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        try
        {
            await ProcessHookLogicAsync(context, cancellationToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Hook processing failed");
            
            // Decide whether to fail the entire operation or continue
            if (IsHookCritical())
            {
                throw; // Fail the entire save operation
            }
            
            // Log and continue for non-critical hooks
            _logger.LogWarning("Non-critical hook failed, continuing with save operation");
        }
    }
}
public class OptimizedHook : IHook
{
    public async Task OnPreSaveChangesAsync(DbContext context, CancellationToken cancellationToken = default)
    {
        // Get only relevant entities to avoid processing everything
        var relevantEntries = context.ChangeTracker.Entries()
            .Where(e => e.State != EntityState.Unchanged && e.Entity is IRelevantEntity)
            .ToList();
        
        if (!relevantEntries.Any()) return;
        
        // Batch operations for efficiency
        var tasks = relevantEntries
            .Select(entry => ProcessEntryAsync(entry, cancellationToken))
            .ToList();
        
        await Task.WhenAll(tasks);
    }
}
[Test]
public async Task AuditLoggingHook_EntityModified_CreatesAuditEntry()
{
    // Arrange
    var context = CreateInMemoryDbContext();
    var currentUserService = new Mock<ICurrentUserService>();
    currentUserService.Setup(x => x.GetCurrentUser()).Returns(new User { Id = "user123" });
    
    var hook = new AuditLoggingHook(Mock.Of<ILogger<AuditLoggingHook>>(), currentUserService.Object);
    
    var customer = new Customer("John", "Doe", "john@example.com");
    context.Customers.Add(customer);
    await context.SaveChangesAsync();
    
    // Modify the entity
    customer.ChangeEmail("john.doe@example.com");
    
    // Act
    await hook.OnPreSaveChangesAsync(context);
    await context.SaveChangesAsync();
    
    // Assert
    var auditEntry = context.AuditEntries.FirstOrDefault();
    Assert.NotNull(auditEntry);
    Assert.Equal("Customer", auditEntry.EntityName);
    Assert.Equal(AuditAction.Update, auditEntry.Action);
    Assert.Equal("user123", auditEntry.UserId);
}
DKNet.EfCore.Hooks integrates seamlessly with other DKNet components:
💡 Architecture Tip: Use DKNet.EfCore.Hooks to implement cross-cutting concerns that need to execute during database operations. Hooks provide a clean way to separate infrastructure concerns from business logic while ensuring consistent behavior across your application. Keep hooks focused on single responsibilities and consider their performance impact on database operations.