diff --git a/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs b/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs
new file mode 100644
index 0000000000..eb232175ff
--- /dev/null
+++ b/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs
@@ -0,0 +1,115 @@
+using FSH.Framework.Core.Context;
+using FSH.Framework.Core.Domain;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+namespace FSH.Framework.Persistence.Inteceptors;
+
+///
+/// Interceptor that automatically populates audit metadata for entities implementing
+/// and handles soft delete for entities implementing .
+/// Uses an recursion guard to prevent StackOverflowException from nested SaveChanges calls.
+///
+public sealed class AuditableEntitySaveChangesInterceptor : SaveChangesInterceptor
+{
+ private readonly ICurrentUser _currentUser;
+ private readonly TimeProvider _timeProvider;
+
+ private static readonly AsyncLocal _isSaving = new();
+
+ public AuditableEntitySaveChangesInterceptor(ICurrentUser currentUser, TimeProvider timeProvider)
+ {
+ _currentUser = currentUser;
+ _timeProvider = timeProvider;
+ }
+
+ public override async ValueTask> SavingChangesAsync(
+ DbContextEventData eventData,
+ InterceptionResult result,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(eventData);
+
+ if (_isSaving.Value)
+ {
+ return await base.SavingChangesAsync(eventData, result, cancellationToken).ConfigureAwait(false);
+ }
+
+ try
+ {
+ _isSaving.Value = true;
+ UpdateAuditEntities(eventData.Context);
+ return await base.SavingChangesAsync(eventData, result, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _isSaving.Value = false;
+ }
+ }
+
+ public override InterceptionResult SavingChanges(
+ DbContextEventData eventData,
+ InterceptionResult result)
+ {
+ ArgumentNullException.ThrowIfNull(eventData);
+
+ if (_isSaving.Value)
+ {
+ return base.SavingChanges(eventData, result);
+ }
+
+ try
+ {
+ _isSaving.Value = true;
+ UpdateAuditEntities(eventData.Context);
+ return base.SavingChanges(eventData, result);
+ }
+ finally
+ {
+ _isSaving.Value = false;
+ }
+ }
+
+ private void UpdateAuditEntities(DbContext? context)
+ {
+ if (context is null) return;
+
+ var userId = _currentUser.IsAuthenticated() ? _currentUser.GetUserId().ToString() : null;
+ var now = _timeProvider.GetUtcNow();
+
+ foreach (var entry in context.ChangeTracker.Entries())
+ {
+ if (entry.Entity is IAuditableEntity)
+ {
+ if (entry.State == EntityState.Added)
+ {
+ entry.Property(nameof(IAuditableEntity.CreatedOnUtc)).CurrentValue = now;
+ entry.Property(nameof(IAuditableEntity.CreatedBy)).CurrentValue = userId;
+ }
+ else if (entry.State == EntityState.Modified || entry.HasChangedOwnedEntities())
+ {
+ entry.Property(nameof(IAuditableEntity.LastModifiedOnUtc)).CurrentValue = now;
+ entry.Property(nameof(IAuditableEntity.LastModifiedBy)).CurrentValue = userId;
+ }
+ }
+
+ if (entry.Entity is ISoftDeletable && entry.State == EntityState.Deleted)
+ {
+ entry.State = EntityState.Modified;
+ entry.Property(nameof(ISoftDeletable.IsDeleted)).CurrentValue = true;
+ entry.Property(nameof(ISoftDeletable.DeletedOnUtc)).CurrentValue = now;
+ entry.Property(nameof(ISoftDeletable.DeletedBy)).CurrentValue = userId;
+ }
+ }
+ }
+}
+
+internal static class EntityEntryExtensions
+{
+ public static bool HasChangedOwnedEntities(this EntityEntry entry) =>
+ entry.References.Any(r =>
+ r.TargetEntry is not null &&
+ r.TargetEntry.Metadata.IsOwned() &&
+ (r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified));
+}
diff --git a/src/BuildingBlocks/Persistence/PersistenceExtensions.cs b/src/BuildingBlocks/Persistence/PersistenceExtensions.cs
index f96a927742..1dfea60315 100644
--- a/src/BuildingBlocks/Persistence/PersistenceExtensions.cs
+++ b/src/BuildingBlocks/Persistence/PersistenceExtensions.cs
@@ -1,4 +1,4 @@
-using FSH.Framework.Persistence.Inteceptors;
+using FSH.Framework.Persistence.Inteceptors;
using FSH.Framework.Shared.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
@@ -31,6 +31,8 @@ public static IServiceCollection AddHeroDatabaseOptions(this IServiceCollection
.Validate(o => !string.IsNullOrWhiteSpace(o.Provider), "DatabaseOptions.Provider is required.")
.ValidateOnStart();
services.AddHostedService();
+ services.TryAddSingleton(TimeProvider.System);
+ services.AddScoped();
services.AddScoped();
return services;
}