AuditInterceptor, SaveChanges öncesinde change tracker’daki her entity’nin audit alanlarını otomatik doldurur. Audit ve soft-delete tek bir interceptor’da birleştirilmiştir — ayrı bir soft-delete interceptor’ı yoktur.
public sealed class AuditInterceptor : SaveChangesInterceptor
{
private readonly IAuditUserContext _userContext;
public AuditInterceptor(IAuditUserContext userContext) => _userContext = userContext;
// ...
}
Hook noktaları
Hem async hem sync SaveChanges yakalanır; ikisi de aynı ApplyAuditFields metodunu çağırır:
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not null)
ApplyAuditFields(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
ApplyAuditFields
Her EntityBase entry’sinin state’ine göre ilgili alanlar set edilir:
private void ApplyAuditFields(DbContext context)
{
var now = DateTime.UtcNow;
var userId = _userContext.UserId;
foreach (var entry in context.ChangeTracker.Entries<EntityBase>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Property(nameof(IAuditableEntity.CreatedAt)).CurrentValue = now;
entry.Property(nameof(IAuditableEntity.CreatedBy)).CurrentValue = userId;
break;
case EntityState.Modified:
entry.Property(nameof(IAuditableEntity.UpdatedAt)).CurrentValue = now;
entry.Property(nameof(IAuditableEntity.UpdatedBy)).CurrentValue = userId;
break;
case EntityState.Deleted:
// Fiziksel silme yerine soft-delete
entry.State = EntityState.Modified;
entry.Property(nameof(ISoftDeletable.DeletedAt)).CurrentValue = now;
entry.Property(nameof(ISoftDeletable.DeletedBy)).CurrentValue = userId;
break;
}
}
}
| Entity state | Yapılan |
|---|
Added | CreatedAt = UtcNow, CreatedBy = userId |
Modified | UpdatedAt = UtcNow, UpdatedBy = userId |
Deleted | State Modified’a çevrilir; DeletedAt = UtcNow, DeletedBy = userId (fiziksel DELETE’e dönüşmez) |
Deleted → Modified dönüşümü soft-delete’in kalbidir: EF Core DELETE SQL’i yerine UPDATE üretir. Detaylar Soft Delete sayfasında.
Tüm zaman damgaları DateTime.UtcNow’dır. now ve userId döngü öncesi bir kez okunur, böylece tek SaveChanges içindeki tüm satırlar tutarlı değer alır.
Aktör kimliği — IAuditUserContext
CreatedBy/UpdatedBy/DeletedBy’a yazılan değer IAuditUserContext.UserId’den gelir:
public interface IAuditUserContext
{
Guid UserId { get; }
}
Bilinçli olarak nullable değil (Guid, Guid? değil): HTTP context’i olmayan akışlarda (background job, seeder, design-time) implementasyon SystemActor.Id döner. Yani audit kolonları asla boş kalmaz.
Design-time’da kullanılan no-op implementasyon:
class NoAuditUserContext : IAuditUserContext
{
public Guid UserId => SystemActor.Id;
}
Neden OnConfiguring’de eklenir?
AuditInterceptor scoped’tur — her request’te taze bir IAuditUserContext alması gerekir (güncel kullanıcı kimliği). Bu yüzden interceptor AddDbContext’in options builder’ına eklenmez; DbContext constructor’ı üzerinden inject edilip OnConfiguring’de eklenir:
// DependencyInjection.cs
services.AddScoped<AuditInterceptor>(); // scoped — her request'te taze IAuditUserContext
services.AddDbContext<DiyanetCleanArchitectureDbContext>((sp, options) =>
{
options.UseNpgsql(...).UseSnakeCaseNamingConvention();
// AuditInterceptor scoped olduğu için buraya EKLENMIYOR.
});
// DiyanetCleanArchitectureDbContext.cs
public DiyanetCleanArchitectureDbContext(
DbContextOptions<DiyanetCleanArchitectureDbContext> options,
IMediator mediator, IEventBus eventBus, AuditInterceptor auditInterceptor) : base(options)
{
_auditInterceptor = auditInterceptor ?? throw new ArgumentNullException(nameof(auditInterceptor));
// ...
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (_auditInterceptor != null)
optionsBuilder.AddInterceptors(_auditInterceptor);
base.OnConfiguring(optionsBuilder);
}
Scoped bir interceptor’ı AddDbContext options’a (singleton internal service provider) eklemek EF Core’un internal service provider cache’ini bozar ve scoped bağımlılığı doğru çözemez. OnConfiguring yolu bu sorunu aşar; DbContext’in iki constructor’ı (parametresiz design-time + DI runtime) bunun için vardır.
Akış özeti
İlgili
Soft Delete
Deleted → Modified dönüşümü ve global query filter.
DbContext & Repository
SaveEntitiesAsync ve interceptor’ın çağrı sırası.
Migrations
Design-time NoAuditUserContext kullanımı.
Data Genel Bakış
Katman rolü ve SaveChanges akışı.