Çok Dilli Veri: Ürün Adını TR ve EN Saklamak — Translation Table, JSON Kolon ve Fallback
Kullanıcının yönettiği çok dilli veri için en esnek seçenek, (EntityId, Culture, Name) key'li bir translation tablosu ve en-GB → en → tr gibi tek yerde kurulan bir fallback zinciridir. Yazı dil başına kolon, translation tablosu, SQL Server 2025 json kolonu ve ayrı bir translation service'i karşılaştırıyor; EF Core 10 LeftJoin ile fallback'i ve Türkçe I için collation seçimini gerçek çıktılarla gösteriyor.
Sürüm notu: Bu yazıdaki C# kodları .NET 10 ve Entity Framework Core (EF Core) 10 ile, SQL (Structured Query Language) örnekleri SQL Server 2025 ile denenmiştir.
Önce Tahmin Et: "iletişim" Diye Arayan Kullanıcı Ne Bulur?
İK (İnsan Kaynakları) Portalı'nda masraf kategorileri var ve her kategorinin Türkçe adı bir tabloda duruyor. Tabloda "İletişim" adlı bir kategori kesin olarak var. Kullanıcı arama kutusuna küçük harfle "iletişim" yazıyor ve backend şu sorguyu çalıştırıyor:
string term = "iletişim";
int count = await db.ExpenseCategoryTranslations
.CountAsync(t => t.Culture == "tr" && t.Name.Contains(term));
Veritabanı SQL Server, kolonun collation'ı sunucunun varsayılanı olan SQL_Latin1_General_CP1_CI_AS, yani büyük küçük harfe duyarsız. Sence count kaç çıkar? İkinci soru: aynı tablo ORDER BY Name ile sıralanınca "Işık ve enerji" mi önce gelir, "İletişim" mi? Cevaplar yazının ortasında; ikisi de çok dilli verinin en sinsi tuzağını taşıyor.
Bağlam ve Problem
İK Portalı ilk yıl yalnız Türkiye'deki şirketlere satıldı ve her şey Türkçeydi. Sonra iki müşteri yurt dışında ofis açtı: biri Londra'da 40 kişilik bir ekip, diğeri Berlin'de 15 kişi. Arayüz çevirisi kolay kısımdı; Angular tarafında i18n (internationalization) dosyaları, backend'de resx ile buton ve menü metinleri çevrildi. Asıl problem verinin kendisi çıktı: masraf kategorileri ("Ulaşım", "Konaklama"), izin türleri, departman adları ve satın alma kataloğundaki ürün adları kullanıcının dilinde görünmeliydi.
Bu, arayüz çevirisinden farklı bir problem. Resx dosyası deploy ile gelir ve geliştirici yönetir; masraf kategorisini ise müşterinin İK yöneticisi ekran üzerinden ekler, adını değiştirir, pasife alır. Çeviri de aynı yaşam döngüsünü izlemeli: kategori eklenirken Türkçe ad zorunlu, İngilizce ad opsiyonel, Almanca belki hiç girilmeyecek. 120 tenant'ın ortalama 35 kategorisi, 18 izin türü ve 40 departmanı var; yani çevrilecek satır sayısı birkaç bin, ama bu satırlar her listede, her raporda ve her aramada okunuyor.
Üç soru tasarımı belirliyor: çeviri nerede saklanacak, kullanıcının diline çeviri yoksa ne gösterilecek (fallback) ve arama ile sıralama hangi dilin kurallarıyla yapılacak.
Belirtiler
Bu problemi yaşadığını şuradan anlarsın: tabloda NameTr, NameEn gibi kolonlar var ve yeni bir dil istendiğinde ilk iş migration yazmak. Kodda lang == "en" ? x.NameEn : x.NameTr kalıbı onlarca yerde tekrar ediyor ve biri unutulduğunda İngilizce ekranda Türkçe bir ad beliriyor. İngilizce adı girilmemiş bir kategori dropdown'da boş satır olarak görünüyor. Raporlama ekibi "hangi kategorilerin çevirisi eksik?" sorusuna cevap veremiyor, çünkü eksik çeviri boş string ile girilmiş bir çeviriden ayırt edilemiyor. Türkçe kullanıcılar "Işık" ile "İletişim"in yanlış sırada durduğunu, aramada büyük İ ile başlayan kayıtların bulunmadığını söylüyor.
Naif Çözüm ve Neden Kırılır
İlk sürüm en kısa yoldan gitti: her dil için bir kolon.
public class ExpenseCategory
{
public int Id { get; set; }
public string Code { get; set; } = "";
public string NameTr { get; set; } = "";
public string NameEn { get; set; } = "";
}
string lang = "en";
var items = await db.ExpenseCategories
.Select(c => new { c.Code, Name = lang == "en" ? c.NameEn : c.NameTr })
.ToListAsync();
foreach (var i in items) Console.WriteLine($"{i.Code}: '{i.Name}'");
Kolonlar NOT NULL ve property'ler "" ile başlıyor, çünkü "null istemiyoruz" kararı verilmiş. İngilizce adı henüz girilmemiş "Işık ve enerji" kategorisi için çıktı:
TRAVEL: 'Transport'
ENERGY: ''
Kırılma anı tam burası: eksik çeviri, boş bir değer olarak saklandı ve kullanıcıya boş satır gösterildi. Fallback yazmak için her sorguda NameEn != "" ? NameEn : NameTr demen gerekiyor. Berlin ofisi Almanca istediğinde ise NameDe kolonu için migration, entity değişikliği ve bu ternary ifadelerinin hepsine yeni bir dal eklemek gerekiyor. Dil sayısı veriyle değil şemayla sınırlı hale geliyor.
Seçenekler
A) Dil Başına Kolon
Naif çözümün disiplinli hali: NameTr NOT NULL, NameEn NULL, fallback'i tek bir projection helper'ında toplamak. İki dil kesinse ve üçüncüsü yol haritasında yoksa kabul edilebilir. Sorgu basit, index'leme kolay, her dil kolonuna ayrı collation verilebilir (NameTr için Turkish_100_CI_AS). Bedeli: dil eklemek şema değişikliği.
B) Translation Tablosu
Çeviri, ana entity'den ayrı bir tabloda satır olarak durur: (CategoryId, Culture, Name). Dil eklemek veri eklemektir; eksik çeviri "satır yok" demektir ve sorgulanabilir. Bedeli her okumada bir join ve fallback mantığının SQL'e taşınması.
public class ExpenseCategory
{
public int Id { get; set; }
public string Code { get; set; } = "";
public bool IsActive { get; set; } = true;
public List<ExpenseCategoryTranslation> Translations { get; set; } = [];
}
public class ExpenseCategoryTranslation
{
public int CategoryId { get; set; }
public string Culture { get; set; } = "";
public string Name { get; set; } = "";
}
C) JSON Kolon
Çeviriler entity'nin kendi satırında bir JSON (JavaScript Object Notation) belgesi olarak durur. EF Core 10 complex type'ları JSON'a map edebiliyor ve SQL Server 2025'te yeni json veri tipini kullanıyor. Bunun için EF'e compatibility level'ı söylemek gerekiyor; UseCompatibilityLevel(170) ya da UseAzureSql olmadan aynı model nvarchar(max) kolon üretiyor:
public class Department
{
public int Id { get; set; }
public LocalizedText Name { get; set; } = new();
}
public class LocalizedText
{
public string Tr { get; set; } = "";
public string? En { get; set; }
}
modelBuilder.Entity<Department>().ComplexProperty(d => d.Name, n => n.ToJson());
options.UseSqlServer(connectionString, o => o.UseCompatibilityLevel(170));
CREATE TABLE [Departments] (
[Id] int NOT NULL IDENTITY,
[Name] json NOT NULL,
CONSTRAINT [PK_Departments] PRIMARY KEY ([Id])
);
Sorgular JSON_VALUE ... RETURNING ile çevriliyor ve fallback tek satırda yazılabiliyor:
SELECT COALESCE(JSON_VALUE([d].[Name], '$.En' RETURNING nvarchar(max)), JSON_VALUE([d].[Name], '$.Tr' RETURNING nvarchar(max)))
FROM [Departments] AS [d]
ORDER BY [d].[Id]
Dikkat: complex type'ın property'leri sabit olduğu için bu hâliyle dil eklemek yine kod değişikliği. Dili gerçekten veri yapmak istersen JSON'u {"tr": "...", "en": "..."} sözlüğü olarak saklayıp kendin sorgulaman gerekir; EF Core 10'da Dictionary property'sini JSON'a map eden yerleşik bir destek yok. Bir de index konusu var: json kolon normal bir index'in key kolonu olamıyor; denediğimizde SQL Server şunu döndü: Column 'Name' in table 'dbo.Departments' is of a type that is invalid for use as a key column in an index or statistics. SQL Server 2025'teki CREATE JSON INDEX ... FOR ('$.En', '$.Tr') bu boşluğu kapatıyor, ama unique constraint ya da dile özgü collation hâlâ veremiyorsun.
D) Ayrı Çeviri Servisi
Çeviriler veritabanında değil, bir çeviri yönetim sisteminde ya da ayrı bir service'te tutulur; uygulama category.TRAVEL.name gibi key'lerle sorar. Ürün kataloğu gibi profesyonel çevirmenlerin çalıştığı, onay akışı olan içerik için mantıklı. Kullanıcının ekrandan girdiği veri için ağır: her kayıt için iki sistemde tutarlılık ve ekstra bir ağ çağrısı demek.
İK Portalı'nda kategori ve izin türü gibi kullanıcı tarafından yönetilen reference data için B'yi seçiyoruz; departman adı gibi yalnız gösterimde kullanılan, aranmayan field'lar için C kabul edilebilir.
Uygulama: Şema ve Fallback Zinciri
Translation tablosunun kuralları şemada duruyor: her kategori için her dilde en fazla bir çeviri (composite key), aynı dilde iki kategori aynı adı taşıyamaz (unique index) ve boş string çeviri sayılmaz (check constraint).
modelBuilder.Entity<ExpenseCategoryTranslation>(b =>
{
b.HasKey(t => new { t.CategoryId, t.Culture });
b.Property(t => t.Culture).HasMaxLength(10).IsUnicode(false);
b.Property(t => t.Name).HasMaxLength(100);
b.HasIndex(t => new { t.Culture, t.Name }).IsUnique();
b.ToTable(t => t.HasCheckConstraint("CK_ExpenseCategoryTranslations_Name", "LEN([Name]) > 0"));
});
Unique constraint'in hangi dilde olacağı sorusunun cevabı "her dilde kendi içinde": (Culture, Name). Türkçede iki ayrı "Yemek" kategorisi olamaz, ama İngilizcede "Meals" ile Türkçede "Yemek" aynı kategoriye ait olabilir. Kurallar ihlal edildiğinde gelen mesajlar:
The INSERT statement conflicted with the CHECK constraint "CK_ExpenseCategoryTranslations_Name". The conflict occurred in database "HrPortal", table "dbo.ExpenseCategoryTranslations", column 'Name'.
Cannot insert duplicate key row in object 'dbo.ExpenseCategoryTranslations' with unique index 'IX_ExpenseCategoryTranslations_Culture_Name'. The duplicate key value is (en, Transport).
Fallback zinciri kullanıcının dilinden başlayıp nötr dile, oradan da tenant'ın varsayılan diline iniyor. .NET'te bunun hazır bir modeli var: CultureInfo.Parent.
public static class CultureChain
{
public static string[] Build(string? requested, string fallback)
{
List<string> chain = [];
if (!string.IsNullOrWhiteSpace(requested))
{
for (CultureInfo c = CultureInfo.GetCultureInfo(requested); c.Name.Length > 0; c = c.Parent)
{
if (!chain.Contains(c.Name)) chain.Add(c.Name);
}
}
if (!chain.Contains(fallback)) chain.Add(fallback);
return [.. chain];
}
}
en-GB -> en-GB -> en -> tr
de-DE -> de-DE -> de -> tr
tr-TR -> tr-TR -> tr
(none) -> tr
Zinciri SQL'e taşımanın iki yolu var: her kategori için bir subquery ile "en iyi" çeviriyi seçmek ya da her halka için bir LEFT JOIN yapıp COALESCE ile birleştirmek. İkincisi daha okunur SQL üretiyor ve her join primary key üzerinden seek yapıyor. .NET 10'la gelen LeftJoin operatörünü EF Core 10 doğrudan çeviriyor:
public static IQueryable<CategoryDto> Localized(
this IQueryable<ExpenseCategory> categories,
IQueryable<ExpenseCategoryTranslation> translations,
string[] chain)
{
// Zincirde en fazla üç halka var: en-GB -> en -> tr
string c0 = chain[0];
string c1 = chain.Length > 1 ? chain[1] : chain[0];
string c2 = chain[^1];
string collation = SqlCollation.For(c0);
return categories
.LeftJoin(translations.Where(t => t.Culture == c0),
c => c.Id, t => t.CategoryId, (c, t0) => new { c, t0 })
.LeftJoin(translations.Where(t => t.Culture == c1),
x => x.c.Id, t => t.CategoryId, (x, t1) => new { x.c, x.t0, t1 })
.LeftJoin(translations.Where(t => t.Culture == c2),
x => x.c.Id, t => t.CategoryId, (x, t2) => new { x.c, x.t0, x.t1, t2 })
.Select(x => new
{
x.c.Id,
x.c.Code,
Name = x.t0!.Name ?? x.t1!.Name ?? x.t2!.Name,
Culture = x.t0 != null ? c0 : x.t1 != null ? c1 : x.t2 != null ? c2 : null
})
.OrderBy(x => EF.Functions.Collate(x.Name ?? x.Code, collation))
.Select(x => new CategoryDto(x.Id, x.Code, x.Name ?? x.Code, x.Culture ?? "-"));
}
CategoryDto yalnız adı değil, adın hangi dilden geldiğini de taşıyor. Arayüz fallback'e düşen satırı farklı gösterebilir, yönetim ekranı "eksik çeviriler" listesini bu field'dan çıkarabilir. en-GB için üretilen SQL:
SELECT [e].[Id], [e].[Code], COALESCE([e1].[Name], [e3].[Name], [e5].[Name], [e].[Code]), ISNULL(CASE
WHEN [e1].[CategoryId] IS NOT NULL AND [e1].[Culture] IS NOT NULL THEN @c00
WHEN [e3].[CategoryId] IS NOT NULL AND [e3].[Culture] IS NOT NULL THEN @c10
WHEN [e5].[CategoryId] IS NOT NULL AND [e5].[Culture] IS NOT NULL THEN @c20
END, N'-')
FROM [ExpenseCategories] AS [e]
LEFT JOIN (
SELECT [e0].[CategoryId], [e0].[Culture], [e0].[Name]
FROM [ExpenseCategoryTranslations] AS [e0]
WHERE [e0].[Culture] = @c0
) AS [e1] ON [e].[Id] = [e1].[CategoryId]
LEFT JOIN (
SELECT [e2].[CategoryId], [e2].[Culture], [e2].[Name]
FROM [ExpenseCategoryTranslations] AS [e2]
WHERE [e2].[Culture] = @c1
) AS [e3] ON [e].[Id] = [e3].[CategoryId]
LEFT JOIN (
SELECT [e4].[CategoryId], [e4].[Culture], [e4].[Name]
FROM [ExpenseCategoryTranslations] AS [e4]
WHERE [e4].[Culture] = @c2
) AS [e5] ON [e].[Id] = [e5].[CategoryId]
WHERE [e].[IsActive] = CAST(1 AS bit)
ORDER BY COALESCE([e1].[Name], [e3].[Name], [e5].[Name], [e].[Code]) COLLATE Latin1_General_100_CI_AS
Endpoint, dili ASP.NET Core'un request localization middleware'inden alıyor. Desteklenen diller tr, en ve en-GB; listede olmayan bir dil gelirse middleware varsayılan tr'ye düşüyor:
builder.Services.Configure<RequestLocalizationOptions>(o =>
{
o.SetDefaultCulture("tr").AddSupportedCultures("tr", "en", "en-GB").AddSupportedUICultures("tr", "en", "en-GB");
});
app.UseRequestLocalization();
app.MapGet("/api/expense-categories", async (HttpContext http, HrPortalDbContext db, CancellationToken ct) =>
{
string culture = http.Features.Get<IRequestCultureFeature>()!.RequestCulture.UICulture.Name;
string[] chain = CultureChain.Build(culture, "tr");
List<CategoryDto> items = await db.ExpenseCategories
.Where(c => c.IsActive)
.Localized(db.ExpenseCategoryTranslations, chain)
.ToListAsync(ct);
http.Response.Headers.ContentLanguage = culture;
http.Response.Headers.Vary = "Accept-Language";
return Results.Ok(items);
});
Londra'daki bir kullanıcının isteği ve Berlin'deki bir kullanıcının isteği:
Accept-Language: en-GB,en;q=0.9
Content-Language: en-GB
[{"id":4,"code":"LODGING","name":"Accommodation","culture":"en"},{"id":3,"code":"COMMS","name":"Communication","culture":"en"},{"id":5,"code":"ENERGY","name":"Işık ve enerji","culture":"tr"},{"id":6,"code":"REFRESH","name":"Refreshments","culture":"en"},{"id":2,"code":"MEALS","name":"Subsistence","culture":"en-GB"},{"id":1,"code":"TRAVEL","name":"Transport","culture":"en"}]
Accept-Language: de-DE,de;q=0.9
Content-Language: tr
[{"id":6,"code":"REFRESH","name":"Çay ve ikram","culture":"tr"},{"id":5,"code":"ENERGY","name":"Işık ve enerji","culture":"tr"},{"id":3,"code":"COMMS","name":"İletişim","culture":"tr"},{"id":4,"code":"LODGING","name":"Konaklama","culture":"tr"},{"id":1,"code":"TRAVEL","name":"Ulaşım","culture":"tr"},{"id":2,"code":"MEALS","name":"Yemek","culture":"tr"}]
Üç halka da çalışıyor: "Subsistence" en-GB'ye özgü çeviriden, "Transport" nötr en'den, "Işık ve enerji" Türkçe varsayılandan geldi. Vary: Accept-Language header'ı önemli: bu yanıt bir cache'e girecekse key'in bir parçası dil olmalı, yoksa Londra'daki kullanıcı Berlin'in Türkçe listesini görür. Angular tarafında da aynı ilke geçerli: kullanıcı dili değiştirdiğinde bir HTTP (Hypertext Transfer Protocol) interceptor yeni Accept-Language değerini göndermeli ve dropdown'lar gibi reference data listeleri yeniden yüklenmeli; bellekte tutulan eski dildeki liste en sık rastlanan hata.
Uygulama: Arama, Sıralama ve Collation
Şimdi baştaki soruya dönelim. "iletişim" araması, tabloda "İletişim" varken şu sonucu verdi:
Contains: 0, Turkish collation: 1
SQL_Latin1_General_CP1_CI_AS büyük küçük harfe duyarsız, ama Türkçe kurallarını bilmiyor: onun için küçük i'nin büyüğü noktasız I, İ ise ayrı bir harf. Aynı tabloda farklı terimlerle denediğimiz LIKE 'terim%' sorgularının eşleşme sayıları:
Term Latin1 Latin1_100 Turkish Turkish_100
iletişim 0 0 1 1
ILETIŞIM 0 0 0 0
İLETİŞİM 0 0 1 1
ışık 1 1 1 1
IŞIK 0 0 1 1
Türkçe collation'da i ile İ, ı ile I eşleşiyor; Latin collation'larda tersine i ile I eşleşiyor. Bu, C#'taki Türkçe I tuzağının (C# serisindeki "C# String'ler: Interpolation, StringBuilder ve Türkçe I Tuzağı" yazısı) veritabanındaki karşılığı. Çözüm, sorgunun diline göre collation seçmek. EF.Functions.Collate bunu sorgu bazında yapıyor:
public static class SqlCollation
{
public static string For(string culture) =>
culture.StartsWith("tr", StringComparison.OrdinalIgnoreCase)
? "Turkish_100_CI_AS"
: "Latin1_General_100_CI_AS";
}
int found = await db.ExpenseCategoryTranslations
.CountAsync(t => t.Culture == "tr"
&& EF.Functions.Like(EF.Functions.Collate(t.Name, SqlCollation.For("tr")), "%" + term + "%"));
SELECT [e].[CategoryId], [e].[Culture], [e].[Name]
FROM [ExpenseCategoryTranslations] AS [e]
WHERE [e].[Culture] = 'tr' AND [e].[Name] COLLATE Turkish_100_CI_AS LIKE @p
Sıralama sorusunun cevabı da aynı yerden geliyor. Türkçe alfabede I (ı) İ'den (i) önce gelir:
Default: Çay ve ikram, İletişim, Işık ve enerji, Konaklama, Ulaşım, Yemek
Turkish: Çay ve ikram, Işık ve enerji, İletişim, Konaklama, Ulaşım, Yemek
Bedeli şu: kolona COLLATE uygulanan bir ifade, kolonun kendi collation'ıyla kurulmuş index'i seek için kullanamaz. Birkaç bin satırlık reference data'da bu fark ölçülemeyecek kadar küçük. Ürün kataloğu gibi milyonlarca satırlık bir tabloda ise her dil için kendi collation'ında bir computed column ve index ya da full-text search düşünmelisin.
JSON kolonda (seçenek C) aynı soru bir katman daha derin: SQL Server 2025'in json tipi string'leri içeride Latin1_General_100_BIN2_UTF8 ile saklıyor. Bizim denememizde JSON_VALUE sonucu veritabanının varsayılan collation'ıyla döndü ve Türkçe sıralama için yine açık bir COLLATE gerekti. Kısacası JSON seçeneği collation problemini çözmüyor, sadece saklama biçimini değiştiriyor.
Trade-off Tablosu
| Kriter | A) Dil başına kolon | B) Translation tablosu | C) JSON kolon | D) Ayrı çeviri servisi |
|---|---|---|---|---|
| Veri bütünlüğü | Yüksek; ama eksik çeviri boş string'e kayabilir | Yüksek; composite key, unique ve check constraint | Orta; belge içi kural ve unique constraint yok | Düşük; iki sistem arası tutarlılık uygulamada |
| Sorgu karmaşıklığı | Düşük | Orta; dil başına bir LEFT JOIN | Düşük; JSON_VALUE ve COALESCE | Yüksek; veritabanı dışı birleştirme |
| Performans | En iyi; normal index | İyi; join'ler primary key üzerinden | Okumada iyi, aramada JSON index gerekir | Ağ çağrısı ve cache gerekir |
| Dil eklemek | Migration ve kod değişikliği | Yalnız veri | Complex type ile kod değişikliği | Yalnız veri |
| Dile özgü collation | Kolon başına verilebilir | Sorguda COLLATE | Sorguda COLLATE | Serviste |
| Geri dönülebilirlik | Kolay | Kolay | Orta | Zor |
| Ekip bilişsel yükü | Düşük | Orta | Orta | Yüksek |
Ne Zaman Kullanma
Translation tablosunu, dil sayısı gerçekten iki ve sabitse kurma; NameTr ve nullable NameEn ile tek bir projection helper'ı yeterli ve daha hızlı. Yalnız arayüz metinleri çok dilliyse, yani veri tek dilde kalıyorsa, bu pattern'e hiç gerek yok; resx ve Angular i18n işini görür. Kullanıcının serbest girdiği içerik (izin talebindeki açıklama, faturadaki not) çevrilmez; onu tek dilde, yazıldığı dilde saklarsın. Makine çevirisini kayda yazıp gerçek çeviri gibi göstermek de ayrı bir hata: kaynağını işaretlemeden saklarsan kimse hangi metnin onaylı olduğunu bilmez.
Kontrol Listesi
- Eksik çeviri "satır yok" ya da
NULLile ifade ediliyor; boş string check constraint ile engelleniyor. - Varsayılan dil (genellikle tenant'ın dili) zorunlu, diğer diller opsiyonel.
- Fallback zinciri tek bir yerde kuruluyor:
en-GB → en → tr. - Yanıt, adın hangi dilden geldiğini taşıyor; eksik çeviri raporu bu field'dan çıkıyor.
- Unique constraint dil içinde:
(Culture, Name). - Arama ve sıralama sorgunun diline uygun collation ile yapılıyor; Türkçe I için test var.
- Dile göre değişen yanıtlarda
Vary: Accept-Languagevar ve cache key dili içeriyor. - JSON seçeneğinde
UseCompatibilityLevel(170)bilinçli seçildi ve arama için JSON index planlandı.
İlgili Pattern'ler ve Kaynaklar
Bu pattern serinin "Enum mı Lookup Tablosu mu? Reference Data'yı Kodda ve Veritabanında Tutmak" yazısının devamı: orada kod listesini veritabanına taşımıştık, burada o listenin adlarını çok dilli yapıyoruz. Translation tablosu, her tenant'ın kendi kategorilerini yönettiği bir yapıda bir sonraki yazının konusu olan tenant izolasyonuyla birlikte çalışmalı. Mekanik ayrıntılar için EF Core serisindeki "EF Core 10 Yenilikleri: Named Query Filter, LeftJoin ve JSON'da Complex Type" ve "EF Core 8 Yenilikleri: Complex Type, Primitive Collection ve SqlQuery<T>" yazılarına, Türkçe karakter davranışı için C# serisindeki Türkçe I yazısına bak. Yanıtın cache'lenmesi planlanan Caching serisinin konusu. Kaynaklar: Microsoft Learn'de "What's New in EF Core 10" (JSON type support ve compatibility level 170 notu), "JSON data type" (SQL Server 2025 json tipi, Unicode Transformation Format, 8-bit (UTF-8) ve code point sırasına göre binary collation (BIN2) ile saklama, index kısıtları), "CREATE JSON INDEX" ve ASP.NET Core "Globalization and localization" belgeleri; SQL Server collation belgelerindeki Windows collation listesi. Seçenekler arasındaki ayrım için Bill Karwin'in "SQL Antipatterns" kitabındaki EAV (Entity-Attribute-Value) tartışması da iyi bir uyarı: translation tablosu dar ve tipli kaldığı sürece EAV'ye dönüşmez.
Küçük Bir Deneme
★☆☆ Translation tablosuna bir kategori için de çevirisi ekle ve de-DE isteğinin sonucunun culture alanının değiştiğini gör. Sonra de için CultureChain'in ürettiği zinciri yazdır.
★★☆ Yönetim ekranı için "eksik çeviriler" sorgusunu yaz: her aktif kategori için en çevirisi olmayanları listele. LeftJoin ile ve NOT EXISTS ile iki versiyon yaz, üretilen SQL'i karşılaştır.
★★★ Departman adlarını {"tr": "...", "en": "..."} biçiminde bir sözlük olarak json kolonda sakla (complex type yerine), JSON_VALUE ile fallback'li bir sorguyu raw SQL olarak yaz ve CREATE JSON INDEX ile $.en yolunu index'le.
Çeviriler artık veride, ama bu tablolar 120 tenant'ın verisini aynı veritabanında tutuyor. A şirketinin "Ulaşım" kategorisi B şirketinin listesinde görünürse bu bir çeviri hatası değil, bir güvenlik ihlali olur. Bir sonraki yazıda multi-tenancy'de veri izolasyonunu konuşacağız: database-per-tenant, shared schema ve SQL Server row-level security.