← Back to all posts

Soft Delete vs Hard Delete — MSSQL'de Hangi Yaklaşım Ne Zaman?

Soft Delete vs Hard Delete — MSSQL'de Hangi Yaklaşım Ne Zaman?

Soft Delete vs Hard Delete — MSSQL'de Hangi Yaklaşım Ne Zaman?

Silmek Göründüğü Kadar Basit Değil

Kullanıcı "Sil" butonuna tıkladı. Ne olmalı?

Çoğu geliştirici için cevap açıktır: DELETE FROM Orders WHERE OrderId = @id. Satır gitti, iş bitti.

Ama production'da işler bu kadar basit değildir. Müşteri silinmiş siparişini neden göremediğini soruyor. Muhasebe geçmiş yılın faturasını arıyor. Düzenleyici kurum son 5 yılın işlem kaydını talep ediyor. Destek ekibi "bir şeyler yanlış gitti" diyor ama veri zaten yok.

Silme kararı, uygulamanızın veri yaşam döngüsü hakkındaki en önemli tasarım kararlarından biridir. Bu yazı her iki yaklaşımı, trade-off'larını ve .NET'te nasıl doğru uygulanacağını gösteriyor.


Hard Delete — Gerçek Silme

Verinin veritabanından fiziksel olarak kaldırılmasıdır. En basit, en doğrudan yaklaşım.

DELETE FROM Orders WHERE OrderId = '3fa85f64-5717-4562-b3fc-2c963f66afa6';

Satır gitti. Geri dönüşü yok. Transaction log'da iz kalır — ama bu da zamanla temizlenir.

Hard Delete Ne Zaman Doğrudur?

✅ Kişisel veri silme talebi — KVKK/GDPR uyumu zorunlu
✅ Geçici veriler — oturum kayıtları, OTP kodları, temp dosyalar
✅ Kullanıcının kendi oluşturduğu taslaklar
✅ Log ve izleme verileri — belirli süreden sonra otomatik temizlik
✅ Test verileri
✅ Hiçbir iş kuralı geçmiş veriye bağlı değilse

Hard Delete'in Tehlikeleri

-- Bir müşteriyi sildik
DELETE FROM Customers WHERE CustomerId = @id;

-- Ama Orders tablosunda bu müşteriye ait kayıtlar var
-- Foreign key varsa: hata
-- Foreign key yoksa: orphan kayıtlar, veri tutarsızlığı

Cascade delete tanımlanmışsa ilişkili tüm veriler de gider — istenen bu olmayabilir.


Soft Delete — Mantıksal Silme

Veri fiziksel olarak silinmez. Bunun yerine "silindi" olarak işaretlenir. Uygulama bu kaydı artık göstermez ama veri veritabanında yaşamaya devam eder.

-- Hard delete yerine
UPDATE Orders
SET
    IsDeleted = 1,
    DeletedAt = GETUTCDATE(),
    DeletedBy = @userId
WHERE OrderId = @id;

Sorgu tarafında filtre eklenir:

-- Artık her sorguda bu filtre gerekli
SELECT * FROM Orders WHERE IsDeleted = 0;

Soft Delete Ne Zaman Doğrudur?

✅ İş verisinin geçmişi kritik — finans, sağlık, hukuk
✅ Audit log zorunluluğu var — kim ne zaman sildi?
✅ Geri alma (undo) özelliği gerekli
✅ Düzenleyici uyum — BDDK, SPK kayıt saklama zorunlulukları
✅ İlişkisel veri bütünlüğü korunmalı
✅ Raporlama geçmiş dönemi kapsıyor

Tablo Tasarımı

Temel Soft Delete Kolonları

ALTER TABLE Orders ADD
    IsDeleted       BIT           NOT NULL DEFAULT 0,
    DeletedAt       DATETIME2     NULL,
    DeletedBy       NVARCHAR(100) NULL;

Audit Log ile Zenginleştirilmiş Versiyon

-- Tüm tablolara uygulanacak ortak kolon seti
CREATE TABLE Orders (
    -- Domain kolonları
    OrderId         UNIQUEIDENTIFIER NOT NULL DEFAULT NEWSEQUENTIALID(),
    CustomerId      UNIQUEIDENTIFIER NOT NULL,
    TotalAmount     DECIMAL(18,2)    NOT NULL,
    Status          TINYINT          NOT NULL DEFAULT 0,

    -- Audit kolonları
    CreatedAt       DATETIME2        NOT NULL DEFAULT GETUTCDATE(),
    CreatedBy       NVARCHAR(100)    NOT NULL,
    UpdatedAt       DATETIME2        NULL,
    UpdatedBy       NVARCHAR(100)    NULL,

    -- Soft delete kolonları
    IsDeleted       BIT              NOT NULL DEFAULT 0,
    DeletedAt       DATETIME2        NULL,
    DeletedBy       NVARCHAR(100)    NULL,

    CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (OrderId)
);

Filtered Index — Performans İçin Zorunlu

Soft delete'in en büyük dezavantajı performanstır. Her sorguda IsDeleted = 0 filtresi eklenir ve bu filtresiz bir index'te table scan'e yol açar.

Çözüm: Filtered index.

-- Sadece silinmemiş kayıtları indexle
CREATE NONCLUSTERED INDEX IX_Orders_CustomerId_Active
ON Orders (CustomerId, CreatedAt DESC)
WHERE IsDeleted = 0;

CREATE NONCLUSTERED INDEX IX_Orders_Status_Active
ON Orders (Status, CreatedAt DESC)
WHERE IsDeleted = 0;

-- Silinmiş kayıtlar için ayrı index (arşiv sorguları için)
CREATE NONCLUSTERED INDEX IX_Orders_Deleted
ON Orders (DeletedAt DESC)
WHERE IsDeleted = 1;

Bu yaklaşımla SQL Server yalnızca aktif kayıtları indexler. Silinmiş milyonlarca kayıt index boyutunu şişirmez.


.NET Implementasyonu

Base Entity

// Tüm entity'lerin miras aldığı temel sınıf
public abstract class BaseEntity
{
    public Guid Id { get; protected set; } = Guid.NewGuid();
    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
    public string CreatedBy { get; private set; } = string.Empty;
    public DateTime? UpdatedAt { get; private set; }
    public string? UpdatedBy { get; private set; }
}

public abstract class SoftDeletableEntity : BaseEntity
{
    public bool IsDeleted { get; private set; }
    public DateTime? DeletedAt { get; private set; }
    public string? DeletedBy { get; private set; }

    public void Delete(string deletedBy)
    {
        if (IsDeleted)
            throw new DomainException("Bu kayıt zaten silinmiş.");

        IsDeleted = true;
        DeletedAt = DateTime.UtcNow;
        DeletedBy = deletedBy;
    }

    public void Restore()
    {
        if (!IsDeleted)
            throw new DomainException("Bu kayıt silinmemiş, geri alınamaz.");

        IsDeleted = false;
        DeletedAt = null;
        DeletedBy = null;
    }
}

// Domain entity
public class Order : SoftDeletableEntity
{
    public Guid CustomerId { get; private set; }
    public decimal TotalAmount { get; private set; }
    public OrderStatus Status { get; private set; }

    private Order() { } // EF Core için

    public static Order Create(Guid customerId, decimal totalAmount)
    {
        return new Order
        {
            CustomerId = customerId,
            TotalAmount = totalAmount,
            Status = OrderStatus.Pending
        };
    }
}

EF Core Global Query Filter

Her sorguda IsDeleted = 0 yazmak zorunda kalmamak için EF Core'un global query filter özelliğini kullanın:

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Tüm SoftDeletableEntity türevlerine otomatik filtre uygula
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (typeof(SoftDeletableEntity).IsAssignableFrom(entityType.ClrType))
            {
                var method = typeof(AppDbContext)
                    .GetMethod(nameof(ApplySoftDeleteFilter),
                        BindingFlags.NonPublic | BindingFlags.Static)!
                    .MakeGenericMethod(entityType.ClrType);

                method.Invoke(null, new object[] { modelBuilder });
            }
        }

        base.OnModelCreating(modelBuilder);
    }

    private static void ApplySoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
        where TEntity : SoftDeletableEntity
    {
        modelBuilder.Entity<TEntity>()
            .HasQueryFilter(e => !e.IsDeleted);
    }

    // SaveChanges'i override et — hard delete'i engelle
    public override Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        foreach (var entry in ChangeTracker.Entries<SoftDeletableEntity>())
        {
            if (entry.State == EntityState.Deleted)
            {
                // Fiziksel silmeyi engelle, soft delete'e çevir
                entry.State = EntityState.Modified;
                entry.Entity.Delete(_currentUserService.UserId);
            }
        }

        return base.SaveChangesAsync(ct);
    }
}

Filteri Bypass Etmek — Silinmişleri Görmek

Global filter bazen bypass edilmesi gerekir — arşiv ekranı, admin paneli, geri alma işlemi:

// Silinmişler dahil tüm kayıtlar
var allOrders = await _context.Orders
    .IgnoreQueryFilters()
    .Where(o => o.CustomerId == customerId)
    .ToListAsync();

// Sadece silinmişler
var deletedOrders = await _context.Orders
    .IgnoreQueryFilters()
    .Where(o => o.IsDeleted && o.CustomerId == customerId)
    .OrderByDescending(o => o.DeletedAt)
    .ToListAsync();

CQRS ile Soft Delete Command

public record DeleteOrderCommand(Guid OrderId) : IRequest<Unit>;

public class DeleteOrderCommandHandler
    : IRequestHandler<DeleteOrderCommand, Unit>
{
    private readonly AppDbContext _context;
    private readonly ICurrentUserService _currentUser;

    public DeleteOrderCommandHandler(
        AppDbContext context,
        ICurrentUserService currentUser)
    {
        _context = context;
        _currentUser = currentUser;
    }

    public async Task<Unit> Handle(
        DeleteOrderCommand command,
        CancellationToken ct)
    {
        var order = await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == command.OrderId, ct)
            ?? throw new NotFoundException(nameof(Order), command.OrderId);

        // Domain entity üzerinde soft delete
        order.Delete(_currentUser.UserName);

        await _context.SaveChangesAsync(ct);

        return Unit.Value;
    }
}

// Geri alma command'ı
public record RestoreOrderCommand(Guid OrderId) : IRequest<Unit>;

public class RestoreOrderCommandHandler
    : IRequestHandler<RestoreOrderCommand, Unit>
{
    private readonly AppDbContext _context;

    public RestoreOrderCommandHandler(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Unit> Handle(
        RestoreOrderCommand command,
        CancellationToken ct)
    {
        // IgnoreQueryFilters — silinmiş kaydı bul
        var order = await _context.Orders
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(o => o.Id == command.OrderId && o.IsDeleted, ct)
            ?? throw new NotFoundException("Silinmiş sipariş bulunamadı.");

        order.Restore();
        await _context.SaveChangesAsync(ct);

        return Unit.Value;
    }
}

Unique Constraint Sorunu

Soft delete'in en sinsi problemi unique constraint'lerdir. Silinmiş bir kayıt hâlâ veritabanında duruyorsa, aynı değerle yeni kayıt eklemeye çalıştığınızda constraint ihlali oluşur.

-- Örnek: Email unique olmalı
-- Kullanıcı hesabını sildi (soft delete)
-- Aynı email ile tekrar kayıt olmak istiyor
-- ❌ UNIQUE constraint ihlali!

CREATE UNIQUE INDEX UX_Users_Email
ON Users (Email)
WHERE IsDeleted = 0;  -- Sadece aktif kayıtlar için unique zorunluluğu

.NET tarafında:

modelBuilder.Entity<User>()
    .HasIndex(u => u.Email)
    .IsUnique()
    .HasFilter("[IsDeleted] = 0");  // Sadece aktif kayıtlar unique

Arşivleme Stratejisi — Şişen Tabloları Yönetmek

Soft delete'in uzun vadeli dezavantajı tablo şişmesidir. Yıllar içinde milyonlarca "silinmiş" kayıt aktif verilerle aynı tabloda birikir.

Bölümleme — Arşiv Tablosu

-- Ana tablo — aktif veriler
CREATE TABLE Orders (
    OrderId     UNIQUEIDENTIFIER NOT NULL,
    -- diğer kolonlar...
    IsDeleted   BIT NOT NULL DEFAULT 0,
    DeletedAt   DATETIME2 NULL,
    CONSTRAINT PK_Orders PRIMARY KEY (OrderId)
);

-- Arşiv tablosu — eski silinmiş veriler
CREATE TABLE Orders_Archive (
    OrderId         UNIQUEIDENTIFIER NOT NULL,
    -- diğer kolonlar...
    IsDeleted       BIT NOT NULL,
    DeletedAt       DATETIME2 NULL,
    ArchivedAt      DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    CONSTRAINT PK_Orders_Archive PRIMARY KEY (OrderId)
);
-- Periyodik arşivleme job'ı — SQL Agent ile çalıştır
-- 1 yıldan eski silinmiş kayıtları arşive taşı
BEGIN TRANSACTION;

    INSERT INTO Orders_Archive
    SELECT *, GETUTCDATE()
    FROM Orders
    WHERE IsDeleted = 1
      AND DeletedAt < DATEADD(YEAR, -1, GETUTCDATE());

    DELETE FROM Orders
    WHERE IsDeleted = 1
      AND DeletedAt < DATEADD(YEAR, -1, GETUTCDATE());

COMMIT;

Hangfire ile Otomatik Arşivleme

public class ArchiveService
{
    private readonly AppDbContext _context;
    private readonly ILogger<ArchiveService> _logger;

    public ArchiveService(AppDbContext context, ILogger<ArchiveService> logger)
    {
        _context = context;
        _logger = logger;
    }

    // Her gece 02:00'de çalışır
    [AutomaticRetry(Attempts = 3)]
    public async Task ArchiveOldDeletedRecordsAsync()
    {
        var cutoffDate = DateTime.UtcNow.AddYears(-1);

        var oldDeletedOrders = await _context.Orders
            .IgnoreQueryFilters()
            .Where(o => o.IsDeleted && o.DeletedAt < cutoffDate)
            .ToListAsync();

        if (!oldDeletedOrders.Any())
        {
            _logger.LogInformation("Arşivlenecek kayıt bulunamadı.");
            return;
        }

        // Arşiv tablosuna taşı
        // Ana tablodan sil
        _context.Orders.RemoveRange(oldDeletedOrders);
        await _context.SaveChangesAsync();

        _logger.LogInformation(
            "{Count} sipariş arşivlendi.", oldDeletedOrders.Count);
    }
}

KVKK / GDPR — Kişisel Veriyi Gerçekten Silmek

Soft delete ve KVKK çelişir gibi görünür — ama çözüm anonimleştirmedir. Veri yapısı korunur, kişisel bilgi silinir.

public record AnonymizeUserCommand(Guid UserId) : IRequest<Unit>;

public class AnonymizeUserCommandHandler
    : IRequestHandler<AnonymizeUserCommand, Unit>
{
    private readonly AppDbContext _context;

    public AnonymizeUserCommandHandler(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Unit> Handle(
        AnonymizeUserCommand command,
        CancellationToken ct)
    {
        var user = await _context.Users
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(u => u.Id == command.UserId, ct)
            ?? throw new NotFoundException(nameof(User), command.UserId);

        // Kişisel verileri anonimleştir — hard delete değil
        user.Anonymize();
        await _context.SaveChangesAsync(ct);

        return Unit.Value;
    }
}

// User entity'de
public void Anonymize()
{
    Email = $"anonymized_{Id}@deleted.local";
    Name = "Silinmiş Kullanıcı";
    Phone = null;
    Address = null;
    TaxNumber = null;
    IsDeleted = true;
    DeletedAt = DateTime.UtcNow;
}

Bu yaklaşımla ilişkisel bütünlük korunur — siparişler, işlemler, log kayıtları yerinde durur — ama kişisel veri artık yoktur.


Karşılaştırma Tablosu

Kriter Hard Delete Soft Delete
Uygulama kolaylığı ✅ Basit ⚠️ Orta
Depolama ✅ Az ⚠️ Fazla
Performans ✅ İyi ⚠️ Filtre gerekli
Veri kurtarma ❌ Yok ✅ Mümkün
Audit log ❌ Zor ✅ Kolay
KVKK uyumu ✅ Kolay ⚠️ Anonimleştirme
Düzenleyici uyum ❌ Sorunlu ✅ Uygun
Unique constraint ✅ Sorunsuz ⚠️ Dikkat gerekli
Tablo boyutu ✅ Küçük ⚠️ Şişme riski

Hibrit Yaklaşım — İkisini Birlikte Kullanmak

Tek bir strateji seçmek zorunda değilsiniz. Veri tipine göre karar verin:

Finansal işlemler, siparişler, faturalar
    → Soft delete + arşivleme

Kullanıcı hesapları
    → Soft delete + anonimleştirme (KVKK talebi gelince)

Oturum kayıtları, OTP kodları, temp veriler
    → Hard delete (TTL ile otomatik)

Ürün ve katalog verisi
    → Soft delete (satış geçmişi korunmalı)

Log ve izleme verileri
    → Retention policy + hard delete

Denetim Listesi

✅ Hangi tablonun soft, hangisinin hard delete kullanacağı belirlendi
✅ Soft delete tablolarında IsDeleted, DeletedAt, DeletedBy kolonları var
✅ Filtered index oluşturuldu — WHERE IsDeleted = 0
✅ Unique constraint'ler filtered index ile güncellendi
✅ EF Core global query filter aktif
✅ SaveChanges override edildi — yanlışlıkla hard delete engellendi
✅ IgnoreQueryFilters admin ve arşiv ekranlarında kullanılıyor
✅ Arşivleme job'ı tanımlandı — tablo şişmesi önlendi
✅ KVKK talebi için anonimleştirme akışı hazır
✅ Geri alma (restore) özelliği gerekli tablolarda implemente edildi

Temel Çıkarımlar

  • Silmek basit görünür, etkileri karmaşıktır — her tablo için bilinçli karar verin
  • Finansal ve iş verisi için soft delete neredeyse her zaman doğru seçimdir
  • Filtered index olmadan soft delete performans sorununa davetiye çıkarır
  • Unique constraint'leri filtered index ile güncellemek unutulmaması gereken detaydır
  • KVKK hard delete gerektirmez — anonimleştirme daha sağlıklı bir çözümdür
  • Tablo şişmesini önlemek için arşivleme stratejisi baştan planlanmalıdır

Silme butonu kullanıcı için basit bir eylemdir. Arka planda ne olacağı ise mimarinin en önemli kararlarından biridir. Bu kararı sonradan değiştirmek çok maliyetlidir — baştan doğru tasarlayın.