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.