Saklama Süresi, Silme ve Anonimleştirme: "Hesabımı Sil", Soft Delete, Backup'lar, Cache'ler ve Periyodik İmha Job'ı
"Hesabımı sil" talebi tek bir DELETE değil, verinin her parçası için ayrı bir karardır: profil silinir, siparişler yasal süre boyunca kişiden koparılıp saklanır, süre dolunca anonimleşir. Bookstore'da foreign key tuzağını, soft delete'in neden yetmediğini, kopyaların silme yollarını, periyodik imha job'ını, imha kaydını ve backup'tan geri yüklemede silinen müşterinin geri gelmesini erasure ledger ile çözmeyi gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, ASP.NET Core 10, Entity Framework Core 10.0.15 (SQLite) ve xUnit 2.9 ile derlenip denenmiştir. Hukuki tarih ve süreler 15 Kasım 2026'da mevzuat.gov.tr ve kvkk.gov.tr'den doğrulanmıştır.
Bu seri bir geliştiricinin bakışıdır, hukuki tavsiye değildir. Saklama sürelerine, hangi kaydın silineceğine ve hangisinin saklanacağına hukuk danışmanın ve mali müşavirin karar verir; bu yazıdaki süreler örnektir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) Linus adlı müşteri hesabını silmek istiyor. Gece 02.00'de alınan backup'tan sonra talebi işliyorsun, müşteri satırı siliniyor. Ertesi hafta bir disk arızası yüzünden veritabanı o geceki backup'tan geri yükleniyor. Linus sistemde mi? Bir de daha basit bir soru: db.Customers.Remove(linus) ve SaveChangesAsync, siparişi olan bir müşteride çalışır mı?
Saklama Süresinin Kaynakları
KVKK'nın (6698 sayılı Kişisel Verilerin Korunması Kanunu) 4. maddesindeki son ilke, verinin ilgili mevzuatta öngörülen veya işlendiği amaç için gerekli olan süre kadar muhafaza edilmesi. 7. madde de işlenmesini gerektiren sebepler ortadan kalkınca verinin resen ya da ilgili kişinin talebiyle silinmesini, yok edilmesini veya anonim hâle getirilmesini istiyor. Usul ve esaslar Kişisel Verilerin Silinmesi, Yok Edilmesi veya Anonim Hale Getirilmesi Hakkında Yönetmelik'te (28 Ekim 2017 tarihli ve 30224 sayılı Resmî Gazete, 1 Ocak 2018'de yürürlükte). Yönetmelikten koda dönüşen hükümler:
- Silme, verinin ilgili kullanıcılar için hiçbir şekilde erişilemez ve tekrar kullanılamaz hâle getirilmesi (md. 8); yok etme, hiç kimse tarafından erişilemez ve geri getirilemez hâle getirilmesi (md. 9); anonim hâle getirme, başka verilerle eşleştirilse dahi kişiyle ilişkilendirilemeyecek hâle getirilmesi (md. 10).
- Saklama ve imha politikası olan veri sorumlusu, yükümlülüğün doğduğu tarihi izleyen ilk periyodik imhada veriyi imha eder; periyodik imha aralığı politikada belirlenir ve altı ayı geçemez. Politika yükümlülüğü olmayanlar için süre üç ay (md. 11).
- İlgili kişinin talebi en geç otuz gün içinde sonuçlandırılır (md. 12/1, 28 Nisan 2019'da değişik).
- Silme, yok etme ve anonim hâle getirme işlemlerinin kayıtları, diğer yasal yükümlülükler hariç, en az üç yıl saklanır (md. 7/3).
Müşteri profili için süre "hesap açık kaldıkça". Sipariş ve fatura kayıtlarında ise başka kanunlar devreye giriyor: Türk Ticaret Kanunu'nun 82. maddesi ticari defterleri ve kayıtların dayandığı belgeleri on yıl saklatıyor ve süreyi ilgili takvim yılının bitişinden başlatıyor; Vergi Usul Kanunu'nun 253. maddesi de beş yıllık bir muhafaza ödevi koyuyor. GDPR (Avrupa Birliği Genel Veri Koruma Tüzüğü) aynı ayrımı yapıyor: Art. 5(1)(e) storage limitation ilkesini koyuyor, Art. 17 silme hakkını veriyor, Art. 17(3)(b) ise hukuki bir yükümlülüğün işlemeyi gerektirdiği durumu bu hakkın dışında tutuyor. Sonuç: "hesabımı sil" talebi bütün veriyi silmek değil, verinin her parçası için ayrı bir karar.
Sipariş satırına saklama süresini yazma anında hesaplayıp koyuyoruz; böylece imha job'ı bir tarih karşılaştırması yapıyor, mevzuatı yeniden yorumlamıyor:
// Envanterdeki örnek süreler; gerçek değerlerin sahibi hukuk ekibi.
public static class RetentionPolicy
{
public static readonly TimeSpan SignInSessions = TimeSpan.FromDays(180);
// TTK 82: on yıl, kaydın ait olduğu takvim yılının bitişinden itibaren.
public static DateTime OrderBillingRetainUntil(DateTime orderedAtUtc) =>
new DateTime(orderedAtUtc.Year + 1, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddYears(10);
}
"Hesabımı Sil" Akışı
01. yazıda silme talebini 202 Accepted ile bir ErasureRequest kaydı olarak açmıştık. Talebi işleyen servis, envanterdeki kararları sırayla uyguluyor: siparişler yasal süre boyunca kalıyor ama kişiye bağlantısı kesiliyor; oturumlar ve profil siliniyor; talep tamamlandı olarak işaretleniyor ve imha kaydı yazılıyor. Veritabanı dışındaki kopyalar commit'ten sonra siliniyor:
public async Task<ErasureResult> EraseCustomerAsync(int customerId, CancellationToken ct = default)
{
DateTime now = clock.GetUtcNow().UtcDateTime;
// ExecuteUpdate/ExecuteDelete change tracker'ı atlar; önce izlenen eski kopyaları bırak.
db.ChangeTracker.Clear();
await using var tx = await db.Database.BeginTransactionAsync(ct);
// Siparişler yasal süre boyunca kalır ama artık bir kişiyi göstermez.
int orders = await db.Orders.Where(o => o.CustomerId == customerId)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.CustomerId, (int?)null), ct);
int sessions = await db.CustomerSessions.Where(s => s.CustomerId == customerId).ExecuteDeleteAsync(ct);
await db.Customers.Where(c => c.Id == customerId).ExecuteDeleteAsync(ct);
await db.ErasureRequests.Where(r => r.CustomerId == customerId && r.CompletedAtUtc == null)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.CompletedAtUtc, now), ct);
db.Add(new DestructionLogEntry { ExecutedAtUtc = now, Category = "customer-profile", Method = "delete", RecordCount = 1 + sessions, Reason = "erasure request" });
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
// Veritabanı dışındaki kopyalar commit'ten sonra silinir; ledger, restore sonrası tekrar uygulamayı sağlar.
List<string> erased = [];
foreach (IPersonalDataCopy copy in copies)
{
await copy.EraseCustomerAsync(customerId, ct);
erased.Add(copy.Name);
}
await ledger.AppendAsync(customerId, now, ct);
return new ErasureResult(customerId, orders, sessions, [.. erased]);
}
Siparişte kalan fatura adı, adres ve şifreli kimlik numarası hâlâ kişisel veri; bu yüzden bu kayıt "silindi" değil "yasal süre boyunca kısıtlı erişimle saklanıyor" durumunda. Erişimi 03. yazıdaki gibi Accounting rolüyle sınırlı. Gerçek bir uygulamada aynı akış Identity tarafındaki kullanıcı kaydını, login'leri ve token'ları da kapatır; bunu Authentication ve Authorization serisinin "ASP.NET Core Identity ile Hesap Yaşam Döngüsü: Kayıt, E-posta Onayı, Parola Sıfırlama, External Login ve Hesap Silme" yazısında anlattık.
Foreign key tuzağı ve change tracker
Giriş sorusunun ikinci yarısı: siparişi olan bir müşteriyi Entity Framework Core (EF Core) ile doğrudan silmeye çalışınca veritabanı itiraz ediyor, çünkü Orders.CustomerId foreign key'i Restrict ile tanımlı:
plain delete: SQLite Error 19: 'FOREIGN KEY constraint failed'.
Bu hatanın iki yanlış çözümü var. Birincisi cascade delete açmak: müşteriyle birlikte yasal süre boyunca saklanması gereken siparişler de gider. İkincisi müşteri satırını silmekten vazgeçip yalnız bir bayrak koymak: bu da bir sonraki bölümün konusu. Doğrusu yukarıdaki sıra: önce bağımlı kayıtların bağlantısını kes, sonra sil. Bunu yaparken bir EF Core ayrıntısı daha çıktı: ExecuteUpdateAsync ve ExecuteDeleteAsync doğrudan SQL çalıştırır ve change tracker'ı güncellemez. Aynı context'te daha önce yüklenmiş bir Customer instance'ı izleniyorsa silinmiş satırın eski hâli bellekte kalır; testte aynı anahtarla yeni bir satır eklemeye çalışınca "another instance with the same key value for {'Id'} is already being tracked" hatasını bu yüzden aldım. ChangeTracker.Clear() çağrısı bunun için orada.
Soft Delete Silme Sayılır mı
Soft delete pattern'ini Çözüm Desenleri serisinin "Soft Delete'i Doğru Yapmak: IsDeleted, DeletedAt, Filtered Unique Index ve Archive Tablosu" yazısında, global query filter'ı da EF Core serisinin "EF Core'da Global Query Filter: Soft Delete, Multi-Tenancy ve IgnoreQueryFilters" yazısında anlattık. EF Core 10 ile bir entity'ye birden fazla isimli filtre tanımlamak ve IgnoreQueryFilters(["SoftDeletionFilter"]) ile yalnız birini kapatmak mümkün. Bunların hepsi uygulamanın soft-deleted satırı görmemesini sağlar; satır ise veritabanında, backup'ta ve IgnoreQueryFilters çağıran her sorguda durmaya devam eder.
Yönetmeliğin 8. maddesi silmeyi, verinin ilgili kullanıcılar için hiçbir şekilde erişilemez ve tekrar kullanılamaz hâle getirilmesi olarak tanımlıyor. Bir geliştirici olarak okuduğum kadarıyla IsDeleted = true bu tanımı tek başına karşılamaz: veriye erişebilen kullanıcılar, raporlar ve destek araçları hâlâ var. Soft delete, kısa bir geri alma penceresi için ilk adım olabilir; asıl silmeyi ise bu pencereden sonra imha job'ı yapar. Pencerenin uzunluğu da bir saklama süresidir ve envantere girer. Bu yorumun kendi durumuna uyup uymadığını hukuk ekibine sor.
Kopyalar: Veritabanı Tek Yer Değil
02. yazıdaki envanterin "kopyalar" sütunu burada iş listesine dönüşüyor. Bir müşteri kaydının Bookstore'daki kopyaları ve her birinin silinme yolu:

Read replica, primary'deki silmeyi replication ile kendiliğinden alır. Arama index'i ve Redis cache gibi veritabanı dışındaki kopyalar için her birine bir IPersonalDataCopy implementasyonu yazılıyor; cache'te kısa bir TTL (önbellekteki kaydın yaşam süresi) ise silme çağrısı kaçsa bile verinin kendiliğinden düşmesini sağlıyor. Log ve telemetry'de çözüm silmek değil, kişisel verinin oraya hiç girmemesi ve retention süresi; bu, serinin 05. yazısının konusu. Analytics ve e-posta servisi gibi dış sağlayıcılar için sağlayıcının silme API'si ve retention ayarı kullanılır; bunlar aynı zamanda 07. yazıda konuşacağımız alt işleyenler.
Backup'lar ayrı bir sınıf: tek bir müşteriyi bir backup dosyasından silmek pratikte mümkün değil ve backup'ı değiştirmek onu güvenilmez kılar. Yaygın mühendislik pratiği, backup'ları kendi saklama süreleriyle düşürmek ve geri yükleme olduğunda silmeleri yeniden uygulamak. Yönetmelikteki silme tanımı karşısında backup'taki kopyanın nasıl değerlendirileceği ise yine hukuk ekibinin sorusu.
Periyodik İmha Job'ı ve İmha Kaydı
Talep bekleyen müşterileri silmek, süresi dolan oturumları silmek ve yasal süresi biten siparişleri anonimleştirmek aynı job'ın işi:
public async Task<DestructionRun> RunAsync(CancellationToken ct = default)
{
DateTime now = clock.GetUtcNow().UtcDateTime;
List<int> pending = await db.ErasureRequests.Where(r => r.CompletedAtUtc == null)
.Select(r => r.CustomerId).Distinct().ToListAsync(ct);
foreach (int customerId in pending)
{
await erasure.EraseCustomerAsync(customerId, ct);
}
DateTime sessionCutoff = now - RetentionPolicy.SignInSessions;
int sessions = await db.CustomerSessions.Where(s => s.SignedInAtUtc < sessionCutoff).ExecuteDeleteAsync(ct);
// Yasal süreden sonra sipariş yalnız anonim bir satış kaydı olarak kalır.
int orders = await db.Orders.Where(o => o.RetainUntilUtc <= now && o.BillingName != "-")
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.CustomerId, (int?)null)
.SetProperty(o => o.BillingName, "-")
.SetProperty(o => o.ShippingAddress, "-")
.SetProperty(o => o.BillingNationalIdEncrypted, (string?)null)
.SetProperty(o => o.BillingNationalIdIndex, (string?)null), ct);
db.AddRange(
new DestructionLogEntry { ExecutedAtUtc = now, Category = "sign-in-session", Method = "delete", RecordCount = sessions, Reason = "retention 180 days" },
new DestructionLogEntry { ExecutedAtUtc = now, Category = "order-billing", Method = "anonymise", RecordCount = orders, Reason = "retention TTK 82" });
await db.SaveChangesAsync(ct);
return new DestructionRun(pending.Count, sessions, orders);
}
İmha kaydı (DestructionLogEntry) kişisel veri tutmuyor: kategori, yöntem, kayıt sayısı ve sebep. Yönetmeliğin istediği "hangi işlem ne zaman yapıldı" kanıtı bu; içine silinen müşterinin adını yazmak, silinen veriyi başka bir tabloda saklamak olurdu. Anonimleştirilen siparişte kalan tutar ve tarih tek başına kişiye bağlanmıyor, ama küçük bir veri setinde başka verilerle eşleştirilebilir mi diye ayrıca bakmak gerekir; 03. yazıdaki anonimlik çıtası burada da geçerli.
Job'ı API içinde bir BackgroundService günde bir kez çalıştırıyor; Yönetmelik altı aylık bir üst sınır koyuyor, günlük çalışma birikmeyi küçük tutuyor ve 30 günlük talep süresini rahatça karşılıyor. Çok instance'lı bir ortamda job'ın iki kez çalışmaması, yeniden çalıştığında aynı sonucu üretmesi ve kesilince kaldığı yerden devam etmesi gerekir; bunları Background Job'lar ve Scheduled Job'lar serisinde Hangfire ve Quartz.NET ile anlattık. API açılırken job'ın ilk çalışmasının log'u:
info: Bookstore.Api.DestructionWorker[0]
Destruction run: 0 erasure requests, 6 sessions deleted, 0 orders anonymised
Backup'tan Geri Yükleme: Silinmiş Müşteri Geri Gelir
Giriş sorusunun cevabını bir demo ile gördük. Lab komutu altı sentetik müşteri, her birine biri 2014'ten biri bu yıldan iki sipariş ve biri 200 gün önceye ait iki oturum yazıyor; sonra gece backup'ını alıyor, düz silmeyi deniyor, Linus'un talebini job ile işliyor ve en sonunda veritabanını backup'tan geri yüklüyor:
cd tools/Bookstore.PrivacyLab
dotnet run -- erase
plain delete: SQLite Error 19: 'FOREIGN KEY constraint failed'.
run: DestructionRun { ErasureRequests = 1, SessionsDeleted = 5, OrdersAnonymised = 6 }
customers=5 orders=12 orphan orders=7 search-index=[1,2,3,5,6]
log: customer-profile delete 3 (erasure request)
log: sign-in-session delete 5 (retention 180 days)
log: order-billing anonymise 6 (retention TTK 82)
after restore: Linus present = True
ledger replay: 1 customer(s) erased again, Linus present = False
Satırları okuyalım. Job bir talebi işledi: Linus'un profili ve iki oturumu silindi (3 kayıt), iki siparişi kaldı ama kişiden koparıldı. Diğer beş müşterinin 200 günlük oturumları silindi. 2014 siparişlerinin saklama süresi 1 Ocak 2025'te dolduğu için altı sipariş anonimleşti. Restore'dan sonra Linus geri geldi: backup, talebin işlenmesinden önce alınmıştı. Silmeyi geri getiren şey, ana veritabanının ve backup'larının dışında tutulan erasure ledger'ı:
// Backup geri yüklendikten sonra çalışır: backup alındıktan sonra silinen müşteriler aksi hâlde geri gelir.
public async Task<int> ReapplyLedgerAsync(CancellationToken ct = default)
{
int count = 0;
foreach (int id in await ledger.ErasedCustomerIdsAsync(ct))
{
if (await db.Customers.AnyAsync(c => c.Id == id, ct))
{
await EraseCustomerAsync(id, ct);
count++;
}
}
return count;
}
Ledger'ın kendisi de envanterde: içinde yalnız müşteri numarası ve silinme zamanı var, başka hiçbir veriyle bağlantısı yok; tek amacı restore sonrası silmeyi tekrar uygulamak. Ledger ana veritabanında dursaydı, o da aynı backup'tan eski hâline dönerdi ve demo'nun son satırı Linus present = True kalırdı. Süresi dolan siparişlerin anonimleştirilmesi için ledger gerekmiyor: periyodik job tarih karşılaştırması yaptığı için restore'dan sonraki ilk çalışmasında aynı işi tekrar yapar.
Üretimde Dikkat
Restore runbook'una ledger replay adımını yazmazsan bu kod hiç çalışmaz; restore'dan sonra uygulama trafiğe açılmadan önce çalışacak bir adım olmalı. İkinci nokta, job'ın gözlemlenmesi: imha kaydının sayıları bir dashboard'a çıkmalı, job hiç çalışmadıysa ya da beklenmedik ölçüde çok kayıt sildiyse alert üretmeli. Bir filtre hatası yüzünden bütün oturumları silen job da, hiç çalışmayan job da aynı derecede sorun. Üçüncüsü, toplu silmenin maliyeti: milyonlarca satırlık bir tabloda tek bir ExecuteDeleteAsync uzun bir transaction ve lock demek; işi küçük parçalara bölmek gerekir. Dördüncüsü, dış kopyaların güvenilirliği: bu örnekte kopyalar commit'ten sonra sırayla siliniyor; biri hata verirse talep tamamlanmış görünür ama veri arama index'inde kalır. Production'da bu adım transaction'ın içinde bir outbox kaydı olarak yazılmalı ve her kopya için ayrı ayrı, başarılı olana kadar yeniden denenmeli; outbox pattern'ini Messaging ve Event-Driven Architecture serisinin "Transactional Outbox ve Inbox: Dual Write Problemini Anlamak ve Doğru Tasarlamak" yazısında anlattık.
Sık Düşülen Tuzaklar
En yaygın tuzak, foreign key hatasını cascade delete ile "çözmek" ve yasal süre boyunca saklanması gereken siparişleri silmek. İkincisi, soft delete'i silme sanmak ve IsDeleted satırlarının yıllarca tabloda kalması. Üçüncüsü, backup'tan geri yüklemeden sonra silinmiş müşterilerin geri gelmesi; ledger'ı ana veritabanında tutmak bu tuzağı çözmez. Dördüncüsü, imha kaydına silinen kişinin bilgisini yazmak. Beşincisi, periyodik imhayı yalnız ana veritabanı için yazıp arama index'ini, cache'i ve analytics'i unutmak; 02. yazıdaki kopyalar sütunu bunun kontrol listesi.
Bu Yazının Dayandığı Kaynaklar
Erişim tarihi 15 Kasım 2026.
- 6698 sayılı Kanun md. 4 ve 7:
mevzuat.gov.tr/mevzuatmetin/1.5.6698.pdf - Kişisel Verilerin Silinmesi, Yok Edilmesi veya Anonim Hale Getirilmesi Hakkında Yönetmelik (md. 7, 8, 9, 10, 11, 12):
kvkk.gov.tr/Icerik/5441 - 6102 sayılı Türk Ticaret Kanunu md. 82:
mevzuat.gov.tr/MevzuatMetin/1.5.6102.pdf; Vergi Usul Kanunu md. 253:gib.gov.tr/mevzuat/kanun/434 - GDPR Art. 5(1)(e) ve 17:
eur-lex.europa.eu/eli/reg/2016/679/oj; EF Core 10 named query filters:learn.microsoft.com/ef/core/what-is-new/ef-core-10.0/whatsnew
Küçük Bir Deneme
★☆☆ erase demosunda ledger replay satırını yorum satırına al ve restore'dan sonra Linus'un sistemde kaldığını gör. Sonra ledger dosyasını ana veritabanına bir tablo olarak taşıyıp aynı denemeyi yap.
★★☆ Customer'a bir soft delete adımı ekle: talep gelince DeletedAtUtc set edilsin, müşteri 7 gün içinde vazgeçebilsin, job 7 günü geçen satırları gerçekten silsin. Bu 7 günü envantere ve RetentionPolicy'ye ekle.
★★★ Oturum silmeyi 1.000'lik parçalar hâlinde yapan ve her parçadan sonra ilerlemeyi kaydeden bir sürüm yaz; job yarıda kesilip yeniden başlatıldığında aynı satırları iki kez saymadığını ve imha kaydının toplamının doğru kaldığını test et.
Buraya kadar kişisel veriyi veritabanında ve onun kopyalarında yönettik. Bir sonraki yazıda verinin en sessiz kopyalarına bakıyoruz: log'lar, trace'ler, analytics ve hata raporlama araçlarındaki URL'ler, exception mesajları ve IP adresleri.