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.