UZMAN
Soft Delete Pattern (Tam Implementasyon)
Veriyi fiziksel olarak silmek yerine IsDeleted = true flag'i ile gizleme. Yasal saklama zorunlulukları, geri alma (undo) ihtiyacı ve veri kaybı riskini ortadan kaldırır.
Veritabanı sağlayıcısı
Bu sayfadaki eşleşen örnekleri seçilen sağlayıcıya göre gösterir.
Temel Interface & Base Entity
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
string? DeletedBy { get; set; }
}
public abstract class BaseEntity : ISoftDeletable
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}
Interceptor ile Otomatik Soft Delete
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUserService _currentUser;
public SoftDeleteInterceptor(ICurrentUserService currentUser)
{
_currentUser = currentUser;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var context = eventData.Context!;
foreach (var entry in context.ChangeTracker.Entries<ISoftDeletable>())
{
if (entry.State == EntityState.Deleted)
{
// Fiziksel silmeyi engelle → soft delete yap
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
entry.Entity.DeletedBy = _currentUser.UserId;
}
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
Global Filter ile Otomatik Filtreleme
// OnModelCreating'de tüm ISoftDeletable entity'lere otomatik filter
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
{
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted));
var condition = Expression.Equal(property, Expression.Constant(false));
var lambda = Expression.Lambda(condition, parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
}
Silinen Verilere Erişim
// Normal sorgu — silinenleri GÖRMEZ
var activeProducts = await context.Products.ToListAsync();
// Admin paneli — silinenleri de GÖR
var allProducts = await context.Products
.IgnoreQueryFilters()
.ToListAsync();
// Sadece silinenleri getir (recycle bin)
var deletedProducts = await context.Products
.IgnoreQueryFilters()
.Where(p => p.IsDeleted)
.ToListAsync();
// Geri yükleme (undelete)
var product = await context.Products
.IgnoreQueryFilters()
.FirstAsync(p => p.Id == 42);
product.IsDeleted = false;
product.DeletedAt = null;
product.DeletedBy = null;
await context.SaveChangesAsync();
Cascade Soft Delete (İlişkili Kayıtlar)
// Sipariş silinince, sipariş kalemleri de soft-delete olsun
public class CascadeSoftDeleteInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var context = eventData.Context!;
foreach (var entry in context.ChangeTracker.Entries<ISoftDeletable>()
.Where(e => e.State == EntityState.Modified && e.Entity.IsDeleted))
{
// İlişkili navigation'ları da soft-delete yap
foreach (var navigation in entry.Navigations
.Where(n => n.CurrentValue is not null))
{
if (navigation.CurrentValue is IEnumerable<ISoftDeletable> children)
{
foreach (var child in children)
{
child.IsDeleted = true;
child.DeletedAt = DateTime.UtcNow;
}
}
else if (navigation.CurrentValue is ISoftDeletable child)
{
child.IsDeleted = true;
child.DeletedAt = DateTime.UtcNow;
}
}
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
SQL — Normal vs Soft Delete Karşılaştırma
-- context.Products.Remove(product) → Interceptor araya girer:
-- ❌ Fiziksel: DELETE FROM Products WHERE Id = 42
-- ✅ Soft: UPDATE Products SET IsDeleted = 1, DeletedAt = '2024-...', DeletedBy = 'user1' WHERE Id = 42
-- Normal sorgular otomatik filtrelenir:
-- SELECT * FROM Products WHERE IsDeleted = 0 (filter otomatik eklenir)
-- context.Products.Remove(product) → Interceptor araya girer:
-- ❌ Fiziksel: DELETE FROM products WHERE id = 42
-- ✅ Soft: UPDATE products SET is_deleted = TRUE, deleted_at = '2024-...', deleted_by = 'user1' WHERE id = 42
-- Normal sorgular otomatik filtrelenir:
-- SELECT * FROM products WHERE NOT is_deleted (filter otomatik eklenir)
PostgreSQL'de Soft Delete İpuçları:
- Partial Index büyük avantaj: Silinmiş kayıtları index'ten tamamen çıkar → index küçülür, sorgular hızlanır
BITyok →BOOLEANkullanılır, filtre syntax'ı:WHERE NOT is_deleted(koşul direkt boolean)DeletedAtiçinTIMESTAMPTZkullanılır (timezone-aware)
-- PostgreSQL partial index (soft delete performansı):
-- Sadece aktif kayıtları indexle → silinmişler index boyutunu şişirmez
CREATE UNIQUE INDEX ix_users_email_active
ON users (email)
WHERE NOT is_deleted; -- Sadece is_deleted = false olanlar index'te!
-- Tarih bazlı: Sadece son 90 günde silinenler index'te (arşiv yönetimi)
CREATE INDEX ix_products_deleted_recent
ON products (deleted_at)
WHERE is_deleted AND deleted_at > NOW() - INTERVAL '90 days';
// EF Fluent API ile aynı partial index:
builder.HasIndex(u => u.Email)
.IsUnique()
.HasFilter("NOT is_deleted"); // PostgreSQL boolean syntax
// HasDefaultValue — PostgreSQL BOOLEAN doğrudan destekler:
builder.Property(p => p.IsDeleted)
.HasDefaultValue(false); // DEFAULT FALSE (BIT değil!)
Neden partial index önemli? Bir tabloda 10M kayıt var, 9M'ı soft-deleted. Full index 10M satır tarar. Partial index sadece 1M aktif kaydı tutar → 10x daha küçük, 10x daha hızlı.