EFEF Core Handbook

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
  • BIT yok → BOOLEAN kullanılır, filtre syntax'ı: WHERE NOT is_deleted (koşul direkt boolean)
  • DeletedAt için TIMESTAMPTZ kullanı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ı.