Mert Özen Her Satırda Daha İleri
Backend C# ile Günlük .NET API'leri — #03

.NET'te Globalization: CultureInfo, CurrentCulture/CurrentUICulture, Türkçe I ve Linux'ta ICU

Mert Özen 15 Kas 2026 15 dk 8 görüntülenme
.NET'te Globalization: CultureInfo, CurrentCulture/CurrentUICulture, Türkçe I ve Linux'ta ICU

.NET'te CurrentCulture format ve karşılaştırmayı, CurrentUICulture arayüz metnini belirler; ikisi de async akışla taşınır ve container'da ICU yoksa invariant mode'a düşer. Bookstore'un katalog sıralaması ve arama kutusu üzerinden Türkçe sıralamayı, ı/İ dönüşümünü, culture'lı dictionary comparer tuzağını, PredefinedCulturesOnly'yi ve chiseled/alpine image'larda tr-TR'nin nasıl kırıldığını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; container örnekleri .NET 10.0.15 runtime image'larıyla (Ubuntu 24.04 tabanlı aspnet, noble-chiseled ve alpine) çalıştırılmıştır.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) katalog servisi kitap adlarını sıralıyor, arama kutusuna gelen sorguyu büyük harfe çeviriyor ve bir şehir adını karşılaştırıyor. Aynı kod tr-TR ve en-US culture'ında çalışıyor:

string[] titles = ["Zübük", "Çalıkuşu", "Cevdet Bey ve Oğulları", "İnce Memed", "Ivanhoe"];
Array.Sort(titles);
Console.WriteLine(string.Join(", ", titles));
Console.WriteLine("istanbul".ToUpper());
Console.WriteLine("istanbul".Equals("İSTANBUL", StringComparison.OrdinalIgnoreCase));
dotnet run --project samples/Bookstore.Bcl.Samples -- culture-guess --culture tr-TR
Cevdet Bey ve Oğulları, Çalıkuşu, Ivanhoe, İnce Memed, Zübük
İSTANBUL
False

dotnet run --project samples/Bookstore.Bcl.Samples -- culture-guess --culture en-US
Çalıkuşu, Cevdet Bey ve Oğulları, İnce Memed, Ivanhoe, Zübük
ISTANBUL
False

Sıralama iki culture'da farklı; Türkçede Ç ayrı bir harf ve C'den sonra gelir, İngilizcede C'nin aksanlı bir hâli sayılır ve kelimenin geri kalanına göre yer bulur. ToUpper culture'a göre İ ya da I üretiyor. Üçüncü satır ise iki culture'da da False: ordinal karşılaştırma Türkçe İ'yi i'nin büyüğü saymıyor.

Önceki iki yazıda format string'lerinin ve string method'larının culture'a nasıl bağlı olduğunu gördük. Bu yazıda culture'ın kendisine bakıyoruz: CultureInfo hiyerarşisi, CurrentCulture ile CurrentUICulture farkı, async akışta culture'ın taşınması, Türkçe sıralama ve ı/İ dönüşümü, ve aynı kodun ICU (International Components for Unicode; .NET'in culture verisini aldığı Unicode library'si) yüklü ve invariant mode'lu container'larda nasıl farklı davrandığı. Türkçe I tuzağının temeli C# Temelleri serisinin "C# String'ler" yazısında anlatıldı; burada onu üretimdeki karşılıklarıyla ele alıyoruz.

CultureInfo Hiyerarşisi: Invariant, Neutral, Specific

Her culture bir dil ve isteğe bağlı bir bölgeden oluşur. tr-TR specific culture'dır (Türkçe, Türkiye), tr neutral culture'dır (yalnız dil), boş isimli invariant culture ise hiyerarşinin köküdür. Parent zinciri bu üç seviyeyi gezer:

CultureInfo culture = CultureInfo.GetCultureInfo("tr-TR");
while (true)
{
    string name = culture.Name.Length == 0 ? "\"\" (invariant)" : culture.Name;
    Console.WriteLine($"{name,-16} neutral={culture.IsNeutralCulture,-5} EnglishName={culture.EnglishName}, NativeName={culture.NativeName}");
    if (culture.Equals(CultureInfo.InvariantCulture))
        break;
    culture = culture.Parent;
}

CultureInfo cached = CultureInfo.GetCultureInfo("tr-TR");
CultureInfo created = new("tr-TR");
Console.WriteLine($"GetCultureInfo: ReadOnly={cached.IsReadOnly}, same instance={ReferenceEquals(cached, CultureInfo.GetCultureInfo("tr-TR"))}");
Console.WriteLine($"new CultureInfo: ReadOnly={created.IsReadOnly}, UseUserOverride={created.UseUserOverride}");

// ICU iyi biçimli her ismi kabul eder, exception yerine kök veriye düşer.
foreach (string name in new[] { "tr-XX", "xx-YY" })
{
    try
    {
        CultureInfo odd = CultureInfo.GetCultureInfo(name);
        Console.WriteLine($"{name}: EnglishName={odd.EnglishName}, D={new DateTime(2026, 11, 14).ToString("D", odd)}");
    }
    catch (CultureNotFoundException ex)
    {
        Console.WriteLine($"{name}: {ex.GetType().Name}: {ex.Message.Split('\n')[0]} InvalidCultureName={ex.InvalidCultureName}");
    }
}
tr-TR            neutral=False EnglishName=Turkish (Türkiye), NativeName=Türkçe (Türkiye)
tr               neutral=True  EnglishName=Turkish, NativeName=Türkçe
"" (invariant)   neutral=False EnglishName=Invariant Language (Invariant Country), NativeName=Invariant Language (Invariant Country)
GetCultureInfo: ReadOnly=True, same instance=True
new CultureInfo: ReadOnly=False, UseUserOverride=True
tr-XX: EnglishName=Turkish (XX), D=14 Kasım 2026 Cumartesi
xx-YY: EnglishName=xx (YY), D=Saturday, November 14, 2026

İki şekilde culture alabilirsin ve farkları önemli. CultureInfo.GetCultureInfo cache'teki salt okunur instance'ı döner; her çağrıda aynı object gelir. new CultureInfo("tr-TR") her seferinde yeni, değiştirilebilir bir instance oluşturur ve UseUserOverride true'dur: Windows'ta kullanıcı Bölge Ayarları'ndan ondalık ayırıcıyı ya da tarih formatını değiştirdiyse bu instance o değişiklikleri taşır. Sunucuda bu, aynı kodun yöneticinin ayarına göre farklı çıktı üretmesi demek. Formatlama ve karşılaştırma için GetCultureInfo kullan; NumberFormatInfo'yu değiştirmen gerekiyorsa önceki yazıdaki gibi Clone al.

Son iki satır şaşırtıcı: ICU ile çalışan .NET, var olmayan tr-XX ve xx-YY isimlerinde exception atmıyor; dil biliniyorsa onun verisine, bilinmiyorsa kök veriye düşüyor. Config dosyasındaki bir yazım hatası (tr_TR, tr-TK) böylece sessizce geçer. PredefinedCulturesOnly ayarını açınca aynı program bu isimlerde hata veriyor:

DOTNET_SYSTEM_GLOBALIZATION_PREDEFINED_CULTURES_ONLY=true dotnet run --project samples/Bookstore.Bcl.Samples -- culture-hierarchy
tr-XX: CultureNotFoundException: Culture is not supported. (Parameter 'name') InvalidCultureName=tr-xx
xx-YY: CultureNotFoundException: Culture is not supported. (Parameter 'name') InvalidCultureName=xx-yy

Aynı ayar proje dosyasında <PredefinedCulturesOnly>true</PredefinedCulturesOnly> olarak verilebilir. Desteklenen culture listesini config'den okuyan bir uygulamada bu ayar, hatayı ilk istekte değil uygulama açılırken yakalamanı sağlar.

CurrentCulture ve CurrentUICulture

Her thread'in, daha doğrusu her async akışın iki culture'ı vardır. CurrentCulture sayı, tarih ve para formatını ve culture'a duyarlı string karşılaştırmasını belirler. CurrentUICulture ise resource dosyalarından hangi dilde metin yükleneceğini belirler. İkisini ayrı ayarlamak meşru bir senaryo: Türkiye'deki bir kullanıcı arayüzü İngilizce görmek isteyebilir ama fiyatı lira formatında bekler.

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");

decimal total = 1249.50m;
DateTime placed = new(2026, 11, 14);
Console.WriteLine($"CurrentCulture={CultureInfo.CurrentCulture.Name}, CurrentUICulture={CultureInfo.CurrentUICulture.Name}");
Console.WriteLine($"Formatted with CurrentCulture: {total:N2} {placed:D}");
Console.WriteLine($"UI text picked by CurrentUICulture: {OrderPlacedMessage()}");

static string OrderPlacedMessage() => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName switch
{
    "tr" => "Siparişiniz alındı",
    _ => "Your order has been placed",
};
CurrentCulture=tr-TR, CurrentUICulture=en-US
Formatted with CurrentCulture: 1.249,50 14 Kasım 2026 Cumartesi
UI text picked by CurrentUICulture: Your order has been placed

Gerçek uygulamada metni switch ile değil .resx dosyaları ve IStringLocalizer ile seçersin; ASP.NET Core'da kullanıcının culture'ını request bazında belirleyen RequestLocalizationMiddleware ve resource dosyaları ASP.NET Core MVC (Model-View-Controller; sayfaları sunucuda üreten ASP.NET Core yaklaşımı) ve Razor Pages serisinin "MVC ve Razor Pages'te Localization" yazısında anlatılıyor. Burada önemli olan, iki ayarın birbirinden bağımsız olduğu ve format'ı yalnız CurrentCulture'ın belirlediği.

Async Akışta Culture

CurrentCulture .NET Core'da bir AsyncLocal değeri gibi davranır: ExecutionContext ile birlikte await sonrasına, Task.Run'a ve yeni thread'lere taşınır. Hangi durumda neyin taşındığını görmek için culture'ı farklı noktalarda yazdıran bir örnek:

CultureInfo.DefaultThreadCurrentCulture = CultureInfo.GetCultureInfo("en-GB");
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
Print("Main, before await");

await Task.Delay(10);
Print("Main, after await");

await Task.Run(() => Print("Task.Run"));

await ChangeInsideAsync();
Print("Main, after ChangeInsideAsync");

var thread = new Thread(() => Print("new Thread"));
thread.Start();
thread.Join();

var done = new TaskCompletionSource();
ThreadPool.UnsafeQueueUserWorkItem(_ =>
{
    Print("UnsafeQueueUserWorkItem");
    done.SetResult();
}, null);
await done.Task;

static async Task ChangeInsideAsync()
{
    CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
    await Task.Yield();
    Print("Inside ChangeInsideAsync");
}
Main, before await             thread  1  tr-TR  1.234,50
Main, after await              thread  5  tr-TR  1.234,50
Task.Run                       thread  7  tr-TR  1.234,50
Inside ChangeInsideAsync       thread  7  de-DE  1.234,50
Main, after ChangeInsideAsync  thread  7  tr-TR  1.234,50
new Thread                     thread  9  tr-TR  1.234,50
UnsafeQueueUserWorkItem        thread  7  en-GB  1,234.50

Okunması gereken üç şey var. Birincisi, await sonrası farklı bir thread'de devam etsen de culture korunuyor; thread'e değil akışa bağlı. İkincisi, async bir method'un içinde culture'ı değiştirmek çağırana geri sızmıyor: ChangeInsideAsync de-DE yaptı ama ana akış tr-TR'de kaldı. Bu, middleware'in request başında culture'ı ayarlayıp action'ların onu görmesini sağlar, ama bir helper method'un culture'ı "kalıcı olarak" değiştirebileceğini düşünmek yanlış olur. Üçüncüsü, ExecutionContext taşımayan UnsafeQueueUserWorkItem (ve bazı library'lerin kendi thread pool'ları) DefaultThreadCurrentCulture'a düşüyor. Bu değer de ayarlı değilse işletim sisteminin culture'ı gelir. Arka plan işlerinde culture'a güvenmek yerine format çağrılarına culture'ı açıkça vermek bu yüzden daha sağlam.

Türkçe Sıralama: StringComparer ve CompareInfo

Katalogdaki kitap adlarını üç farklı comparer ile sıraladım:

List<string> ordinal = [.. Titles];
ordinal.Sort(StringComparer.Ordinal);
List<string> invariant = [.. Titles];
invariant.Sort(StringComparer.InvariantCulture);
List<string> turkish = [.. Titles];
turkish.Sort(StringComparer.Create(CultureInfo.GetCultureInfo("tr-TR"), ignoreCase: false));
Ordinal                       InvariantCulture              tr-TR
Cevdet Bey ve Oğulları        Çalıkuşu                      Cevdet Bey ve Oğulları
Dune                          Cevdet Bey ve Oğulları        Çalıkuşu
Ivanhoe                       Dune                          Dune
Saatleri Ayarlama Enstitüsü   emma                          emma
Ulysses                       İnce Memed                    Ivanhoe
Zübük                         Ivanhoe                       İnce Memed
emma                          Saatleri Ayarlama Enstitüsü   Saatleri Ayarlama Enstitüsü
Çalıkuşu                      Şeker Portakalı               Şeker Portakalı
Üç İstanbul                   Üç İstanbul                   Ulysses
İnce Memed                    Ulysses                       Üç İstanbul
Şeker Portakalı               Zübük                         Zübük

Ordinal sıralama karakterlerin Unicode değerlerine bakar: büyük harfler küçüklerden önce gelir (emma Z'den sonra), Türkçe harfler alfabenin sonuna atılır. Makine için doğru (deterministik ve hızlı), insan için anlamsız. InvariantCulture dil bağımsız bir Unicode sıralaması uygular; Ç'yi C'nin aksanlı hâli sayıp kelimenin devamına göre yerleştirir, bu yüzden "Çalıkuşu" "Cevdet"ten önce geliyor. tr-TR ise Türk alfabesinin kurallarını uygular: Ç C'den, Ş S'den, Ü U'dan sonra ayrı harflerdir ve I ile İ farklı harflerdir.

StringComparer.Create(culture, ignoreCase) belirli bir culture için comparer üretir; StringComparer.CurrentCulture ise çağrıldığı andaki culture'ı yakalar. Kullanıcıya gösterilen listeyi sıralarken kullanıcının culture'ını açıkça vermek, sunucunun culture'ına bağımlı kalmaktan iyidir. Veritabanındaki sıralama ise collation'ın işidir; SQL Server'da Turkish_100_CI_AS collation'ı ve Türkçe İ tuzağı SQL Temelleri serisinin "SQL Veri Tipleri ve CREATE TABLE" yazısında anlatılıyor. Uygulamada ve veritabanında farklı kurallarla sıralamak, sayfalama yaparken aynı kitabın iki sayfada görünmesine yol açabilir.

Türkçe I: Büyük-Küçük Harf ve Eşleştirme

Türk alfabesinde iki ayrı harf çifti var: i/İ ve ı/I. Diğer dillerde ise yalnız i/I. Bunun sonuçlarını yan yana görelim:

Console.WriteLine($"\"istanbul\".ToUpper(tr)        = {"istanbul".ToUpper(tr)}");
Console.WriteLine($"\"istanbul\".ToUpper(en)        = {"istanbul".ToUpper(en)}");
Console.WriteLine($"\"istanbul\".ToUpperInvariant() = {"istanbul".ToUpperInvariant()}");
Console.WriteLine($"\"ISBN\".ToLower(tr)            = {"ISBN".ToLower(tr)}");
Console.WriteLine($"\"ISBN\".ToLowerInvariant()     = {"ISBN".ToLowerInvariant()}");

string query = "istanbul";
string stored = "İSTANBUL";
Console.WriteLine($"OrdinalIgnoreCase             : {string.Equals(query, stored, StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"InvariantCultureIgnoreCase    : {string.Equals(query, stored, StringComparison.InvariantCultureIgnoreCase)}");
Console.WriteLine($"tr-TR IgnoreCase              : {string.Compare(query, stored, tr, CompareOptions.IgnoreCase) == 0}");
Console.WriteLine($"en-US IgnoreCase              : {string.Compare(query, stored, en, CompareOptions.IgnoreCase) == 0}");
Console.WriteLine($"tr-TR IgnoreCase \"ISTANBUL\"   : {string.Compare(query, "ISTANBUL", tr, CompareOptions.IgnoreCase) == 0}");
"istanbul".ToUpper(tr)        = İSTANBUL
"istanbul".ToUpper(en)        = ISTANBUL
"istanbul".ToUpperInvariant() = ISTANBUL
"ISBN".ToLower(tr)            = ısbn
"ISBN".ToLowerInvariant()     = isbn
OrdinalIgnoreCase             : False
InvariantCultureIgnoreCase    : False
tr-TR IgnoreCase              : True
en-US IgnoreCase              : False
tr-TR IgnoreCase "ISTANBUL"   : False

Kural şu: kullanıcının yazdığı Türkçe metni Türkçe kurallarla karşılaştırmak istiyorsan tr-TR culture'ı ile IgnoreCase kullan; istanbul ile İSTANBUL eşleşir, ISTANBUL ise eşleşmez (Türkçede o kelime "ıstanbul" olurdu). Makine verisinde ("ISBN", HTTP header, enum adı, dosya uzantısı) ToLowerInvariant, ToUpperInvariant ya da OrdinalIgnoreCase kullan; aksi hâlde Türkçe sunucuda "ISBN" "ısbn" olur ve hiçbir switch dalına girmez. Bir ayrıntı daha: invariant büyük harf dönüşümü ı'yı I'ya çevirmez; "çalıkuşu".ToUpperInvariant() sonucu ÇALıKUŞU. Invariant dönüşüm Türkçe metni göstermek için değil, makine verisini normalize etmek içindir.

Bookstore: Arama Kutusunda "istanbul" ve "İSTANBUL"

Katalog aramasında kullanıcı "istanbul", "sehir" ya da "kurk" yazabilir; Türkçe klavyesi olmayan bir kullanıcı ya da hızlı yazan biri aksanları atlar. Aynı sorguları üç yöntemle denedim: tr-TR ve invariant CompareInfo ile IgnoreCase | IgnoreNonSpace, bir de katlama (folding):

string[] catalog = ["İSTANBUL HATIRALAR VE ŞEHİR", "Kürk Mantolu Madonna", "İnce Memed", "Istanbul: Memories and the City"];
string[] queries = ["istanbul", "sehir", "kurk", "ince"];
CompareInfo turkish = CultureInfo.GetCultureInfo("tr-TR").CompareInfo;
CompareInfo invariant = CultureInfo.InvariantCulture.CompareInfo;
const CompareOptions options = CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace;

foreach (string query in queries)
{
    Console.WriteLine($"{query,-9} tr-TR     -> {Hits(title => turkish.IndexOf(title, query, options) >= 0)}");
    Console.WriteLine($"{"",-9} Invariant -> {Hits(title => invariant.IndexOf(title, query, options) >= 0)}");
    string foldedQuery = SearchText.Fold(query);
    Console.WriteLine($"{"",-9} Fold      -> {Hits(title => SearchText.Fold(title).Contains(foldedQuery, StringComparison.Ordinal))}");
}
istanbul  tr-TR     -> İSTANBUL HATIRALAR VE ŞEHİR
          Invariant -> İSTANBUL HATIRALAR VE ŞEHİR | Istanbul: Memories and the City
          Fold      -> İSTANBUL HATIRALAR VE ŞEHİR | Istanbul: Memories and the City
sehir     tr-TR     ->
          Invariant -> İSTANBUL HATIRALAR VE ŞEHİR
          Fold      -> İSTANBUL HATIRALAR VE ŞEHİR
kurk      tr-TR     ->
          Invariant -> Kürk Mantolu Madonna
          Fold      -> Kürk Mantolu Madonna
ince      tr-TR     -> İnce Memed
          Invariant -> İnce Memed
          Fold      -> İnce Memed

Sonuç ilk bakışta ters görünüyor: Türkçe kurallar "sehir" ile "ŞEHİR"i, "kurk" ile "Kürk"ü eşleştirmiyor. Sebebi Türk alfabesinde ş ve ü'nün ayrı harfler olması; IgnoreNonSpace yalnız harfin üzerindeki işareti yok sayar, ayrı bir harfi başka bir harfe eşitlemez. Arama kutusu için istediğimiz ise tam tersi: affedici bir eşleşme. Bunun en öngörülebilir yolu, ilk yazıdaki slug koduyla aynı mantıkla metni katlamak:

public static string Fold(string text)
{
    ArgumentNullException.ThrowIfNull(text);

    string decomposed = text.Normalize(NormalizationForm.FormD);
    var folded = new StringBuilder(decomposed.Length);
    foreach (char c in decomposed)
    {
        if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
            continue;
        folded.Append(c == 'ı' ? 'i' : char.ToLowerInvariant(c));
    }

    return folded.ToString().Normalize(NormalizationForm.FormC);
}

Katlamanın culture'lı karşılaştırmaya göre iki avantajı var. Sonucu kitap kaydedilirken bir kez hesaplayıp veritabanında ayrı bir kolonda saklayabilir, aramayı ordinal ve index'li yapabilirsin. Ayrıca ICU'ya bağlı olmadığı için invariant mode'da da aynı sonucu verir. Gerçek bir katalogda aramayı bir arama motoruna bırakırsın; Türkçe analyzer'lar ve aksan katlama Elasticsearch ile Arama serisinde anlatılıyor.

ICU, NLS ve Invariant Globalization Mode

.NET 5'ten önce Windows'ta culture verisi ve karşılaştırma NLS (National Language Support; Windows'un yerleşik yerel ayar altyapısı) ile, Linux'ta ICU ile yapılıyordu ve iki platform bazı durumlarda farklı sonuç veriyordu. .NET 5'ten beri Windows 10'un 2019 Mayıs güncellemesinden itibaren Windows'ta da işletim sistemiyle gelen ICU kullanılıyor; eski davranış gerekiyorsa System.Globalization.UseNls ayarıyla NLS'e dönülebilir. Linux container'larında ise ICU image'ın içinde olmalı. Olmadığında .NET invariant globalization mode'da çalışır: tüm culture'lar invariant gibi davranır, karşılaştırmalar ordinal olur.

Aynı programın bir teşhis komutunu dört image'da çalıştırdım. ICU'nun yüklü olup olmadığını Microsoft dokümanındaki SortVersion kontrolüyle buluyor, sonra tr-TR ile birkaç işlem yapıyor:

dotnet publish samples/Bookstore.Bcl.Samples -c Release -o pub
docker run --rm -v "${PWD}/pub:/app:ro" -w /app mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Bcl.Samples.dll culture-mode
docker run --rm -v "${PWD}/pub:/app:ro" -w /app mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled Bookstore.Bcl.Samples.dll culture-mode
docker run --rm -e DOTNET_SYSTEM_GLOBALIZATION_PREDEFINED_CULTURES_ONLY=false -v "${PWD}/pub:/app:ro" -w /app mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled Bookstore.Bcl.Samples.dll culture-mode
docker run --rm -v "${PWD}/pub:/app:ro" -w /app mcr.microsoft.com/dotnet/runtime:10.0-alpine dotnet Bookstore.Bcl.Samples.dll culture-mode
== aspnet:10.0
ICU loaded       : True
CurrentCulture   : ""
tr-TR date       : 14 Kasım 2026 Cumartesi
tr-TR money      : ₺1.234,50
ToUpper(tr)      : İSTANBUL
Sort(tr)         : Cevdet Bey ve Oğulları, Çalıkuşu, Ivanhoe, İnce Memed, Zübük
IndexOf("\0")    : 0
ToUpperInvariant : ÇALıKUŞU
== aspnet:10.0-noble-chiseled
ICU loaded       : False
CurrentCulture   : ""
CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. See https://aka.ms/GlobalizationInvariantMode for more information. (Parameter 'name')
IndexOf("\0")    : 4
ToUpperInvariant : ÇALıKUŞU
== aspnet:10.0-noble-chiseled, PREDEFINED_CULTURES_ONLY=false
ICU loaded       : False
CurrentCulture   : ""
tr-TR date       : Saturday, 14 November 2026
tr-TR money      : ¤1,234.50
ToUpper(tr)      : ISTANBUL
Sort(tr)         : Cevdet Bey ve Oğulları, Ivanhoe, Zübük, Çalıkuşu, İnce Memed
IndexOf("\0")    : 4
ToUpperInvariant : ÇALıKUŞU
== runtime:10.0-alpine
ICU loaded       : False
CurrentCulture   : ""
CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. See https://aka.ms/GlobalizationInvariantMode for more information. (Parameter 'name')
IndexOf("\0")    : 4
ToUpperInvariant : ÇALıKUŞU

Ubuntu tabanlı aspnet:10.0 image'ında ICU var ve her şey yerel geliştirme ortamındaki gibi. Chiseled ve alpine image'ları ise DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true ortam değişkeniyle geliyor (docker image inspect ile görebilirsin). Bu modda PredefinedCulturesOnly varsayılan olarak açık, bu yüzden GetCultureInfo("tr-TR") doğrudan CultureNotFoundException atıyor. Yerelde çalışan uygulama container'a girince ilk Türkçe fiyat formatında çöküyor. Ayarı false yaparsan exception kaybolur ama daha kötü bir şey olur: tr-TR sessizce invariant gibi davranır, tarih İngilizce, para sembolü ¤, sıralama ordinal çıkar.

Dört image'ın hepsinde CurrentCulture boş, yani invariant. Container'da LANG ortam değişkeni ayarlı değilse .NET'in varsayılan culture'ı invariant culture olur; -e LANG=tr_TR.UTF-8 ile çalıştırınca tr-TR geliyor. Sunucu tarafında bu aslında iyi bir varsayılan: format'a culture'ı açıkça veren kod her yerde aynı çalışır.

Alpine'da ICU'ya ihtiyacın varsa paketini kurup invariant mode'u kapatırsın. Repo'daki Dockerfile:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish samples/Bookstore.Bcl.Samples -c Release -o /app

FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine
# Alpine image'ında ICU yok, varsayılan olarak invariant mode'da çalışır.
RUN apk add --no-cache icu-data-full icu-libs
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "Bookstore.Bcl.Samples.dll"]
docker build -f docker/samples-alpine-icu.Dockerfile -t bookstore-bcl-samples:alpine-icu .
docker run --rm bookstore-bcl-samples:alpine-icu culture-mode
ICU loaded       : True
CurrentCulture   : ""
tr-TR date       : 14 Kasım 2026 Cumartesi
tr-TR money      : ₺1.234,50
ToUpper(tr)      : İSTANBUL
Sort(tr)         : Cevdet Bey ve Oğulları, Çalıkuşu, Ivanhoe, İnce Memed, Zübük
IndexOf("\0")    : 0
ToUpperInvariant : ÇALıKUŞU

Bunun bir bedeli var: benim ölçümümde alpine runtime image'ı 142 MB'tan 196 MB'a çıktı. Chiseled image'lar için de ICU ve tzdata içeren -extra varyantları var. Hangisini seçeceğin uygulamanın ne yaptığına bağlı: yalnız JSON alıp veren ve culture'ı her yerde açıkça invariant veren bir API invariant mode'da sorunsuz çalışır; kullanıcıya Türkçe tarih, para ya da sıralama gösteren bir uygulamanın ICU'ya ihtiyacı var. Image seçimi, chiseled image'lar ve globalization Docker serisinin "ASP.NET Core'u Container'a Almak" yazısında ayrıntılı.

Üretimde Dikkat

Globalization ayarını uygulamanın bir parçası olarak düşün, ortamın değil. Proje dosyasında InvariantGlobalization ve PredefinedCulturesOnly değerlerini açıkça yaz; böylece image değiştiğinde davranış sessizce değişmez. Uygulama açılırken desteklediğin culture'ları (tr-TR, en-US) bir kez GetCultureInfo ile yükleyip bir sağlık kontrolüne bağlamak, invariant mode'a düşen bir deploy'u ilk dakikada yakalar.

Culture'a bağlı kodu analyzer'lara yakalat. CA1304 ve CA1311 culture vermeden yapılan ToUpper/ToLower çağrılarını, CA1305 culture vermeden yapılan format ve parse'ı, CA1309 ve CA1310 ise ordinal olmayan string karşılaştırmasını işaretler. Bu kuralları "çok uyarı veriyor" diye kapatmak, Türkçe sunucuda ortaya çıkacak hataları saklamak demek. Kuralları kademeli açmak Statik Analiz serisinin ".NET Analyzer'ları" yazısında.

Test ortamını üretimle aynı tut. Geliştirici makinesi Türkçe, CI agent'ı İngilizce, container invariant olduğunda aynı test üç ayrı sonuç verebilir. Culture'a duyarlı testlerde culture'ı test içinde açıkça ayarla, container'da çalışan entegrasyon testlerini üretimdeki image ile koştur.

Sık Düşülen Tuzaklar

Dictionary key'lerinde culture'a bağlı comparer kullanmak. StringComparer.CurrentCultureIgnoreCase oluşturulduğu andaki culture'ı yakalar:

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
var templates = new Dictionary<string, string>(StringComparer.CurrentCultureIgnoreCase)
{
    ["INVOICE"] = "invoice.html",
    ["SHIPPING"] = "shipping.html",
};

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
Console.WriteLine($"comparer created under tr-TR, lookup \"invoice\": {templates.ContainsKey("invoice")}");
Console.WriteLine($"comparer created under tr-TR, lookup \"shipping\": {templates.ContainsKey("shipping")}");

var safe = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
    ["INVOICE"] = "invoice.html",
};
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
Console.WriteLine($"OrdinalIgnoreCase under tr-TR, lookup \"invoice\": {safe.ContainsKey("invoice")}");
comparer created under tr-TR, lookup "invoice": False
comparer created under tr-TR, lookup "shipping": False
OrdinalIgnoreCase under tr-TR, lookup "invoice": True

Uygulama Türkçe bir sunucuda açıldığı için şablon sözlüğü Türkçe kurallarla kuruldu; "INVOICE" ile "invoice" farklı key oldu. Aynı kod İngilizce sunucuda sorunsuz çalışır, bu yüzden hata yalnız bir ortamda görünür. Comparer seçimi C# Koleksiyonlar ve Generics serisinin "C# Dictionary ve HashSet" yazısında ayrıntılı; kural basit: makine key'lerinde StringComparer.Ordinal ya da OrdinalIgnoreCase.

Container'da tr-TR culture'ının bulunamaması. Küçük image'lara geçerken invariant mode'un açık geldiğini unutmak, ilk Türkçe format çağrısında CultureNotFoundException demek. Image değiştirirken teşhis komutunu çalıştır.

CA1304, CA1305 ve CA1309 uyarılarını .editorconfig'te topluca kapatmak. Uyarı sayısını azaltmak için culture'ı her yerde InvariantCulture ile susturmak da aynı derecede yanlış; kullanıcıya gösterilen metinde doğru culture'ı vermek gerekir.

Türkçe metni ordinal büyük-küçük harf karşılaştırmasıyla eşleştirmeye çalışmak. OrdinalIgnoreCase i ile İ'yi eşleştirmez; kullanıcı metninde ya culture'lı karşılaştırma ya da katlama gerekir.

Küçük Bir Deneme

★☆☆ culture-sort örneğine "Ilgaz", "ılık" ve "İlk Aşk" başlıklarını ekle ve üç sütundaki yerlerine bak. StringComparer.Create(tr, ignoreCase: true) ile sıralayınca ne değişiyor?

★★☆ SearchText.Fold için bir unit test sınıfı yaz: "İSTANBUL", "ıstanbul", "Istanbul" ve "istanbul" aynı sonucu vermeli. Testleri bir kez normal, bir kez DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ile çalıştır.

★★★ Bookstore.Api için uygulama açılışında çalışan bir kontrol yaz: tr-TR ve en-US culture'larını yükle, 1234.5m.ToString("C2", tr) sonucunun ₺ içerdiğini doğrula; invariant mode'daysa ya da culture yüklenemiyorsa açık bir log mesajıyla uygulamayı durdur. Kontrolü chiseled ve -extra image'larında dene.

Metin bloğunu burada kapatıyoruz. Bir sonraki yazıda sayılara geçiyoruz: Math.Round'un yuvarlama modları ve Math.Round(2.5)'in neden 2 döndüğü, kampanya indirimini kuruş hassasiyetinde hesaplamak, Random.Shared, sipariş numarası için zamana göre sıralı Guid v7 ve BigInteger.