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

Tarih ve Saat İleri: TimeZoneInfo, IANA/Windows Id'leri, ParseExact, ISO 8601 ve Tarih Aritmetiği

Mert Özen 15 Kas 2026 20 dk 9 görüntülenme
Tarih ve Saat İleri: TimeZoneInfo, IANA/Windows Id'leri, ParseExact, ISO 8601 ve Tarih Aritmetiği

Tarih ve saat hatalarının çoğu üç yerden çıkar: yanlış time zone id'si, Kind'ı belirsiz parse edilmiş bir DateTime ve ay sonunu ya da yaz saatini hesaba katmayan aritmetik. Bookstore'un 5 iş günlük teslim kuralı ve Frankfurt deposu üzerinden TimeZoneInfo'nun Windows/IANA id davranışını, ParseExact ve DateTimeStyles'ı, ISO 8601 round-trip'ini, DateOnly/TimeOnly JSON'unu ve TimeProvider ile test edilebilir tarih hesabını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve Microsoft.Extensions.TimeProvider.Testing 11.0.0 ile derlenip denenmiştir; çıktılar tzdata 2026e yüklü bir Linux container'ında alınmıştır.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) kurumsal müşterilerine kestiği faturaların vadesi "bir ay sonra". 31 Ocak 2027 tarihli bir fatura için aşağıdaki dört satır ne yazar? Son satırda Windows'a özgü bir time zone id'si var ve kod Linux'ta çalışıyor:

DateTime approved = new(2027, 1, 31);
Console.WriteLine(approved.AddMonths(1).ToString("yyyy-MM-dd", Invariant));
Console.WriteLine(approved.AddMonths(1).AddMonths(1).ToString("yyyy-MM-dd", Invariant));
Console.WriteLine(approved.AddMonths(2).ToString("yyyy-MM-dd", Invariant));

TimeZoneInfo warehouse = TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time");
Console.WriteLine(warehouse.Id);
2027-02-28
2027-03-28
2027-03-31
W. Europe Standard Time

AddMonths(1) 31 Ocak'tan 31 Şubat'a gidemeyeceği için ayın son gününe, 28 Şubat'a düşer. Sonra bir ay daha eklediğinde başlangıçtaki "31" bilgisi kaybolmuştur ve sonuç 28 Mart olur; tek seferde iki ay eklemek ise 31 Mart verir. Aynı işi iki farklı yoldan yapan iki kod parçası üç gün farklı vade hesaplar. Son satır da çoğu kişiyi şaşırtır: Windows id'si Linux'ta exception fırlatmadı, hatta Id property'si istediğimiz adı geri verdi.

C# Temelleri serisinin "C# Tarih ve Saat" yazısında tiplerin kendisini, Çözüm Desenleri serisinin "Zaman ve Time Zone" yazısında da zamanın veritabanında nasıl tasarlanacağını anlattık. Bu yazıda her gün yazılan API'lere bakıyoruz: TimeZoneInfo ve platformlar arası id farkı, ParseExact ve DateTimeStyles, ISO'nun (uluslararası standartlar kuruluşu) tarih ve saati metin olarak yazmak için tanımladığı 8601 formatıyla kayıpsız gidiş-dönüş, ay sonu, yaş ve TimeSpan biçimlendirme, en sonda da teslim tarihini hesaplayan bir class'ı TimeProvider ile test edilebilir yapmak. Örnekleri sen de çalıştırabilirsin:

dotnet run --project samples/Bookstore.Bcl.Samples -- date-zones --culture en-US

TimeZoneInfo: Windows ve IANA Id'leri

Time zone kuralları işletim sisteminden gelir. Windows bunları registry'de "Turkey Standard Time", "W. Europe Standard Time" gibi adlarla tutar. Linux ve macOS ise IANA (Internet Assigned Numbers Authority; tz veritabanını yayımlayan kuruluş) adlarını kullanır: "Europe/Istanbul", "Europe/Berlin". Uzun yıllar boyunca Windows'ta yazılıp Linux container'ında çalışan kodun ilk sorunu buydu. .NET 6'dan beri iki tür id de iki platformda kabul ediliyor; dönüşüm için gereken eşleme tablosu ICU'dan (International Components for Unicode; .NET'in culture ve time zone eşleme verisini aldığı library) geliyor. Dört id'yi Linux container'ında aradım:

foreach (string id in new[] { "Turkey Standard Time", "Europe/Istanbul", "W. Europe Standard Time", "Europe/Berlin" })
{
    TimeZoneInfo zone = TimeZoneInfo.FindSystemTimeZoneById(id);
    Console.WriteLine($"{id,-24} -> Id={zone.Id,-24} HasIanaId={zone.HasIanaId,-5} BaseUtcOffset={zone.BaseUtcOffset}");
}

TimeZoneInfo.TryConvertIanaIdToWindowsId("Europe/Istanbul", out string? windowsId);
Console.WriteLine($"Europe/Istanbul -> Windows: {windowsId}");
TimeZoneInfo.TryConvertWindowsIdToIanaId("W. Europe Standard Time", out string? ianaId);
Console.WriteLine($"W. Europe Standard Time -> IANA: {ianaId}");
TimeZoneInfo.TryConvertWindowsIdToIanaId("W. Europe Standard Time", "NL", out string? ianaNl);
Console.WriteLine($"W. Europe Standard Time + NL -> IANA: {ianaNl}");
bool known = TimeZoneInfo.TryConvertIanaIdToWindowsId("Europe/Frankfurt", out string? missing);
Console.WriteLine($"Europe/Frankfurt known: {known} ({missing ?? "null"})");

try
{
    TimeZoneInfo.FindSystemTimeZoneById("Europe/Frankfurt");
}
catch (TimeZoneNotFoundException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
Turkey Standard Time     -> Id=Turkey Standard Time     HasIanaId=False BaseUtcOffset=03:00:00
Europe/Istanbul          -> Id=Europe/Istanbul          HasIanaId=True  BaseUtcOffset=03:00:00
W. Europe Standard Time  -> Id=W. Europe Standard Time  HasIanaId=False BaseUtcOffset=01:00:00
Europe/Berlin            -> Id=Europe/Berlin            HasIanaId=True  BaseUtcOffset=01:00:00
Europe/Istanbul -> Windows: Turkey Standard Time
W. Europe Standard Time -> IANA: Europe/Berlin
W. Europe Standard Time + NL -> IANA: Europe/Amsterdam
Europe/Frankfurt known: False (null)
TimeZoneNotFoundException: The time zone ID 'Europe/Frankfurt' was not found on the local computer.

Üç şey dikkat çekiyor. Birincisi, Id senin verdiğin adı geri verir; HasIanaId ise bu adın hangi sistemden geldiğini söyler. Id'yi bir yere kaydedecek ya da başka bir servise göndereceksen bu fark önemli: Linux'ta "Turkey Standard Time" ile bulduğun zone'un Id'si IANA adı değildir. İkincisi, Windows'tan IANA'ya dönüşüm tek anlamlı değildir. "W. Europe Standard Time" Berlin, Amsterdam, Roma, Viyana gibi birçok IANA zone'una karşılık gelir; bölge kodu vermezsen eşleme tablosunun varsayılanı (burada Berlin) döner, "NL" verirsen Amsterdam. Bugün kuralları aynı olan bu şehirler geçmişte farklı kurallar uyguladı, gelecekte de ayrılabilir. Üçüncüsü, id'ler tahmin edilerek yazılmaz: Frankfurt Almanya'nın en bilinen şehirlerinden biri ama tz veritabanında "Europe/Frankfurt" diye bir zone yok, Almanya "Europe/Berlin" ile temsil ediliyor.

Windows tarafında FindSystemTimeZoneById önce registry'e bakar; IANA id'si ise .NET 6'dan beri ICU üzerinden kabul edilir. Microsoft Learn'deki API sayfasına göre bunun koşulu NLS (National Language Support; Windows'un eski yerel ayar altyapısı) modunun ve globalization invariant mode'un kapalı olması. Serinin 3. yazısında gördüğümüz InvariantGlobalization=true ayarı ICU'yu devreden çıkardığı için id dönüşümü de çalışmaz. Önerim: kodda, configuration'da ve veritabanında her zaman IANA id'si sakla. Windows id'si gereken tek yer bir Windows API'si ya da eski bir sistemse, TryConvertIanaIdToWindowsId ile sınırda dönüştür.

Zone'lar Arası Dönüşüm ve Yaz Saati

Bookstore'un Avrupa siparişlerini Frankfurt'taki bir depo gönderiyor. Kurumsal sipariş İstanbul saatiyle onaylanıyor, depo ise ekranında kendi yerel saatini görmek istiyor. Aynı saatte verilen iki onayı depo saatine çevirelim; arada 25 Ekim 2026'da Avrupa'da yaz saati bitiyor:

TimeZoneInfo istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
TimeZoneInfo warehouse = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

// Kurumsal sipariş İstanbul saatiyle onaylanıyor; depo Frankfurt'ta.
DateTimeOffset[] approvals =
[
    new(2026, 10, 23, 17, 30, 0, TimeSpan.FromHours(3)),
    new(2026, 10, 26, 17, 30, 0, TimeSpan.FromHours(3)),
];

foreach (DateTimeOffset approvedAt in approvals)
{
    DateTimeOffset atWarehouse = TimeZoneInfo.ConvertTime(approvedAt, warehouse);
    Console.WriteLine($"Istanbul {approvedAt:yyyy-MM-dd HH:mm zzz} -> warehouse {atWarehouse:yyyy-MM-dd HH:mm zzz}  (UTC {approvedAt.UtcDateTime:HH:mm})");
}

DateTime pickupLocal = new(2026, 11, 16, 15, 0, 0, DateTimeKind.Unspecified);
DateTime pickupUtc = TimeZoneInfo.ConvertTimeToUtc(pickupLocal, warehouse);
DateTime pickupIstanbul = TimeZoneInfo.ConvertTime(pickupLocal, warehouse, istanbul);
Console.WriteLine($"Courier pickup 15:00 Berlin = {pickupUtc:HH:mm} UTC = {pickupIstanbul:HH:mm} Istanbul");

DateTimeOffset byId = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(approvals[0], "W. Europe Standard Time");
Console.WriteLine($"ConvertTimeBySystemTimeZoneId: {byId:yyyy-MM-dd HH:mm zzz}");
Istanbul 2026-10-23 17:30 +03:00 -> warehouse 2026-10-23 16:30 +02:00  (UTC 14:30)
Istanbul 2026-10-26 17:30 +03:00 -> warehouse 2026-10-26 15:30 +01:00  (UTC 14:30)
Courier pickup 15:00 Berlin = 14:00 UTC = 17:00 Istanbul
ConvertTimeBySystemTimeZoneId: 2026-10-23 16:30 +02:00

Türkiye 2016'dan beri yaz saati uygulamıyor ve yıl boyu UTC (Coordinated Universal Time; saat dilimlerinin referans aldığı evrensel saat) +03:00'te. Almanya ise ekimin son pazarına kadar +02:00, sonra +01:00. Bu yüzden İstanbul ile depo arasındaki fark bir hafta içinde 1 saatten 2 saate çıktı. "Depo bizden bir saat geride" diye sabit bir fark kodlarsan yılın beş ayı yanlış sonuç alırsın. Kuralı ezberleme, zone'u ver ve dönüşümü TimeZoneInfo'ya bırak.

Overload seçimi de önemli. ConvertTime(DateTimeOffset, TimeZoneInfo) belirli bir anı alır ve hedef zone'daki offset'iyle geri verir; anın kendisi değişmez, bu yüzden en güvenli yol budur. DateTime alan overload'lar ise Kind property'sine bakar: Unspecified bir değeri kaynak zone'un yerel saati, Local bir değeri makinenin zone'u sayar. Kuryenin "Berlin saatiyle 15:00'te gelir" bilgisi bir an değil, bir duvar saati değeri; onu Unspecified olarak kurup kaynak zone'u açıkça vermek doğru.

Olmayan ve İki Kez Yaşanan Saatler

Yaz saatine geçilen gece bir saat atlanır, çıkılan gece bir saat iki kez yaşanır. Depoya "her gün 02:30'da envanter sayımı" gibi bir job planlarsan bu iki gece sorun çıkarır:

TimeZoneInfo warehouse = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
DateTime springGap = new(2026, 3, 29, 2, 30, 0);
DateTime autumnOverlap = new(2026, 10, 25, 2, 30, 0);

Console.WriteLine($"{springGap:yyyy-MM-dd HH:mm} invalid: {warehouse.IsInvalidTime(springGap)}");
Console.WriteLine($"{autumnOverlap:yyyy-MM-dd HH:mm} ambiguous: {warehouse.IsAmbiguousTime(autumnOverlap)}, offsets: {string.Join(" / ", warehouse.GetAmbiguousTimeOffsets(autumnOverlap))}");

try
{
    TimeZoneInfo.ConvertTimeToUtc(springGap, warehouse);
}
catch (ArgumentException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message.Split(" (Parameter")[0]}");
}

DateTime utc = TimeZoneInfo.ConvertTimeToUtc(autumnOverlap, warehouse);
Console.WriteLine($"ConvertTimeToUtc(ambiguous) = {utc:HH:mm} UTC (standard offset chosen)");

DateTimeOffset start = new(2026, 10, 24, 12, 0, 0, TimeSpan.FromHours(2));
DateTimeOffset nextDayWall = new(2026, 10, 25, 12, 0, 0, TimeSpan.FromHours(1));
Console.WriteLine($"Saturday 12:00 -> Sunday 12:00 Berlin = {(nextDayWall - start).TotalHours} hours");
2026-03-29 02:30 invalid: True
2026-10-25 02:30 ambiguous: True, offsets: 01:00:00 / 02:00:00
ArgumentException: The supplied DateTime represents an invalid time.  For example, when the clock is adjusted forward, any time in the period that is skipped is invalid.
ConvertTimeToUtc(ambiguous) = 01:30 UTC (standard offset chosen)
Saturday 12:00 -> Sunday 12:00 Berlin = 25 hours

29 Mart 02:30 Berlin'de hiç yaşanmaz ve dönüşüm exception fırlatır. 25 Ekim 02:30 iki kez yaşanır; ConvertTimeToUtc sormadan standart saati, yani ikinci geçişi seçer. Hangisini istediğin bir iş kuralıdır: sayım job'ı iki kez çalışmamalı, ödeme hatırlatması ise hiç atlanmamalı. Bu yüzden zamanlanmış işlerde IsInvalidTime ve IsAmbiguousTime ile durumu kendin kontrol et ya da işi UTC'de planla. Son satır da bir başka sık hatayı gösteriyor: o hafta sonu Berlin'de "bir gün sonra aynı saat" 25 saat sürüyor. "24 saat sonra" ile "ertesi gün aynı saatte" farklı şeylerdir.

ParseExact ve TryParseExact

Kurumsal müşteriler teslim tarihini bir Excel şablonuyla gönderiyor ve hücreler "14.11.2026 09:30" gibi geliyor. Kabul ettiğin formatları açıkça vermek, DateTime.Parse'ın culture'a göre tahmin yürütmesinden daha güvenli:

string[] formats = ["dd.MM.yyyy HH:mm", "dd.MM.yyyy", "yyyy-MM-dd'T'HH:mm"];
string[] inputs = ["14.11.2026 09:30", "14.11.2026", "2026-11-14T09:30", "14/11/2026", "4.11.2026"];

foreach (string input in inputs)
{
    bool ok = DateTime.TryParseExact(input, formats, Invariant, DateTimeStyles.None, out DateTime value);
    Console.WriteLine($"{input,-18} -> {(ok ? value.ToString("yyyy-MM-dd HH:mm", Invariant) : "rejected")}");
}

try
{
    DateTime.ParseExact("2026-11-14 9:30", "yyyy-MM-dd HH:mm", Invariant);
}
catch (FormatException ex)
{
    Console.WriteLine($"FormatException: {ex.Message}");
}

DateTime single = DateTime.ParseExact("2026-11-14 9:30", "yyyy-MM-dd H:mm", Invariant);
Console.WriteLine($"'H:mm' accepts 9:30 -> {single:HH:mm}");
14.11.2026 09:30   -> 2026-11-14 09:30
14.11.2026         -> 2026-11-14 00:00
2026-11-14T09:30   -> 2026-11-14 09:30
14/11/2026         -> rejected
4.11.2026          -> rejected
FormatException: String '2026-11-14 9:30' was not recognized as a valid DateTime.
'H:mm' accepts 9:30 -> 09:30

Format dizisi sırayla denenir; ilk eşleşen kazanır. ParseExact gerçekten "exact"tır: dd iki hane ister, bu yüzden "4.11.2026" reddedildi; tek haneyi de kabul etmek istiyorsan d.M.yyyy formatını listeye eklersin. HH ile H arasında da aynı fark var. Format string'leri serinin 2. yazısındaki kurallarla aynı; orada gördüğümüz MM/mm karışıklığı parse tarafında daha sinsidir, çünkü yanlış bir değer exception vermeden kabul edilebilir. Format provider olarak InvariantCulture vermek de bilinçli bir seçim: kullanıcı arayüzünden gelmeyen her metin için culture'dan bağımsız parse et. Kullanıcının elle girdiği tarihlerde ise TryParseExact'in sonucuna göre "Tarih gg.aa.yyyy biçiminde olmalı" gibi net bir mesaj dön; exception'ı yakalayıp yutmak hem yavaş hem anlamsız.

DateTimeStyles ve Kind

Parse ettiğin değerin Kind'ı, sonraki her dönüşümü etkiler. Aşağıdaki çıktılar TZ=Europe/Istanbul ile çalışan bir Linux container'ından; senin makinende saatler yerel zone'una göre değişir, asıl önemli olan Kind sütunu:

// Bu demo TZ=Europe/Istanbul ile çalışan bir container'da koşturuldu.
string utcText = "2026-11-14T09:30:00Z";
string noOffset = "2026-11-14T09:30:00";

DateTime plain = DateTime.Parse(utcText, Invariant);
DateTime adjusted = DateTime.Parse(utcText, Invariant, DateTimeStyles.AdjustToUniversal);
DateTime roundtrip = DateTime.Parse(utcText, Invariant, DateTimeStyles.RoundtripKind);
DateTime unspecified = DateTime.Parse(noOffset, Invariant, DateTimeStyles.RoundtripKind);
DateTime assumed = DateTime.Parse(noOffset, Invariant, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);

Console.WriteLine($"Parse(Z)                   -> {plain:HH:mm} Kind={plain.Kind}");
Console.WriteLine($"Parse(Z, AdjustToUniversal)-> {adjusted:HH:mm} Kind={adjusted.Kind}");
Console.WriteLine($"Parse(Z, RoundtripKind)    -> {roundtrip:HH:mm} Kind={roundtrip.Kind}");
Console.WriteLine($"Parse(no offset)           -> {unspecified:HH:mm} Kind={unspecified.Kind}");
Console.WriteLine($"  .ToUniversalTime()       -> {unspecified.ToUniversalTime():HH:mm} (treated as local)");
Console.WriteLine($"Parse(no offset, AssumeUniversal|AdjustToUniversal) -> {assumed:HH:mm} Kind={assumed.Kind}");

DateTimeOffset offset = DateTimeOffset.Parse(noOffset, Invariant);
Console.WriteLine($"DateTimeOffset.Parse(no offset) -> {offset:yyyy-MM-dd HH:mm zzz}");
Parse(Z)                   -> 12:30 Kind=Local
Parse(Z, AdjustToUniversal)-> 09:30 Kind=Utc
Parse(Z, RoundtripKind)    -> 09:30 Kind=Utc
Parse(no offset)           -> 09:30 Kind=Unspecified
  .ToUniversalTime()       -> 06:30 (treated as local)
Parse(no offset, AssumeUniversal|AdjustToUniversal) -> 09:30 Kind=Utc
DateTimeOffset.Parse(no offset) -> 2026-11-14 09:30 +03:00

İlk satır şaşırtıcı ama belgelenmiş bir davranış: sonu Z ile biten bir değeri varsayılan ayarlarla parse edince DateTime onu makinenin yerel saatine çevirir ve Kind=Local döner. Sunucu İstanbul'daysa 09:30 UTC, 12:30 olur. AdjustToUniversal ya da RoundtripKind bunu önler. Offset'siz metin ise Unspecified olur ve buradaki tuzak büyüktür: ToUniversalTime() Unspecified bir değeri yerel saat sayar, bu yüzden aslında UTC olan bir değer üç saat geriye kayar. Aynı kod geliştiricinin makinesinde, UTC'de çalışan bir container'da ve İstanbul'daki bir sunucuda üç farklı sonuç verir. Metnin UTC olduğunu biliyorsan AssumeUniversal | AdjustToUniversal ile bunu açıkça söyle. DateTimeOffset.Parse da offset'siz metne yerel zone'un offset'ini ekler; yani o da makineye bağımlı.

ISO 8601 ile Kayıpsız Gidiş-Dönüş

Bir anı API'ye, kuyruğa ya da log'a yazarken hedef, okuyan tarafın aynı anı ve aynı offset'i geri alabilmesi. Bunun için "o" (round-trip) formatı var:

DateTimeOffset approvedAt = new(2026, 11, 14, 9, 30, 15, 250, TimeSpan.FromHours(3));
string wire = approvedAt.ToString("o", Invariant);
DateTimeOffset back = DateTimeOffset.ParseExact(wire, "o", Invariant);
Console.WriteLine($"DateTimeOffset \"o\" : {wire}");
Console.WriteLine($"Round-trip equal   : {back == approvedAt}, EqualsExact: {back.EqualsExact(approvedAt)}");
Console.WriteLine($"Same instant in UTC: {approvedAt.ToUniversalTime():o}");
Console.WriteLine($"== UTC version     : {approvedAt == approvedAt.ToUniversalTime()}, EqualsExact: {approvedAt.EqualsExact(approvedAt.ToUniversalTime())}");

DateTime utc = new(2026, 11, 14, 6, 30, 15, DateTimeKind.Utc);
DateTime unspecified = new(2026, 11, 14, 6, 30, 15);
Console.WriteLine($"DateTime Utc \"o\"   : {utc:o}");
Console.WriteLine($"Unspecified \"o\"    : {unspecified:o}");
Console.WriteLine($"\"s\" / \"u\"          : {utc:s} / {utc:u}");
Console.WriteLine($"JSON DateTimeOffset: {JsonSerializer.Serialize(approvedAt)}");
Console.WriteLine($"JSON Unspecified   : {JsonSerializer.Serialize(unspecified)}");
DateTimeOffset "o" : 2026-11-14T09:30:15.2500000+03:00
Round-trip equal   : True, EqualsExact: True
Same instant in UTC: 2026-11-14T06:30:15.2500000+00:00
== UTC version     : True, EqualsExact: False
DateTime Utc "o"   : 2026-11-14T06:30:15.0000000Z
Unspecified "o"    : 2026-11-14T06:30:15.0000000
"s" / "u"          : 2026-11-14T06:30:15 / 2026-11-14 06:30:15Z
JSON DateTimeOffset: "2026-11-14T09:30:15.25+03:00"
JSON Unspecified   : "2026-11-14T06:30:15"

"o" formatı offset'i ve 100 nanosaniyelik tick hassasiyetini korur; ParseExact(..., "o", ...) ile geri okunan değer EqualsExact testini de geçer. == operatörü yalnız anı karşılaştırır: İstanbul'daki 09:30 ile UTC'deki 06:30 eşittir. EqualsExact ise offset'in de aynı olmasını ister. Siparişin hangi zone'da verildiğini raporlamak istiyorsan offset'i taşımak bu yüzden değerlidir.

Asıl tehlike son satırlarda. Unspecified bir DateTime "o" ile de, System.Text.Json ile de offset'siz yazılır. Karşı taraf bu metni okuduğunda hangi zone'da olduğunu bilemez ve kendi kuralını uygular; bir önceki bölümde gördüğümüz ToUniversalTime() kayması tam olarak böyle başlar. "s" formatı da offset taşımaz; "u" UTC'ye çevirmez, sadece sona Z ekler, bu yüzden yalnız gerçekten UTC olan bir değerle kullanılmalı. Kural basit: sistem sınırını geçen her an ya DateTimeOffset ya da Kind=Utc bir DateTime olsun.

DateOnly ve TimeOnly

Teslim günü bir an değil, takvimde bir gün. DateOnly ve TimeOnly parse ve JSON tarafında şöyle davranıyor:

DateOnly delivery = new(2026, 11, 20);
TimeOnly slot = new(14, 30);
CultureInfo tr = CultureInfo.GetCultureInfo("tr-TR");

Console.WriteLine($"ToString(tr-TR)     : {delivery.ToString(tr)} {slot.ToString(tr)}");
Console.WriteLine($"ToString(\"O\")       : {delivery:O} {slot:O}");
Console.WriteLine($"ParseExact dd.MM.yyyy: {DateOnly.ParseExact("20.11.2026", "dd.MM.yyyy", Invariant):O}");
Console.WriteLine($"TryParse with time  : {DateOnly.TryParse("2026-11-20 14:30", Invariant, out _)}");

string json = JsonSerializer.Serialize(new DeliverySlot(delivery, slot));
Console.WriteLine($"JSON                : {json}");
Console.WriteLine($"Back                : {JsonSerializer.Deserialize<DeliverySlot>(json)}");

try
{
    JsonSerializer.Deserialize<DeliverySlot>("""{"Day":"20.11.2026","At":"14:30"}""");
}
catch (JsonException ex)
{
    Console.WriteLine($"JsonException       : {ex.Message.Split(" Path:")[0]}");
}

DateOnly fromDateTime = DateOnly.FromDateTime(new DateTime(2026, 11, 20, 23, 59, 0));
Console.WriteLine($"FromDateTime        : {fromDateTime:O}, ToDateTime: {delivery.ToDateTime(slot):O}");
Console.WriteLine($"ISO week (.NET 10)  : {ISOWeek.GetWeekOfYear(delivery)}");
ToString(tr-TR)     : 20.11.2026 14:30
ToString("O")       : 2026-11-20 14:30:00.0000000
ParseExact dd.MM.yyyy: 2026-11-20
TryParse with time  : False
JSON                : {"Day":"2026-11-20","At":"14:30:00"}
Back                : DeliverySlot { Day = 11/20/2026, At = 2:30 PM }
JsonException       : The JSON value could not be converted to Bookstore.Bcl.Samples.Dates.DateDemos+DeliverySlot.
FromDateTime        : 2026-11-20, ToDateTime: 2026-11-20T14:30:00.0000000
ISO week (.NET 10)  : 47

System.Text.Json DateOnly'yi yyyy-MM-dd, TimeOnly'yi HH:mm:ss olarak yazar ve yalnız ISO biçimini okur; "20.11.2026" gelen bir JSON'u kabul etmez. Excel'den gelen yerel biçimli tarihleri bu yüzden JSON'a girmeden önce ParseExact ile çevir. DateOnly.TryParse saat içeren bir metni de reddeder; bu iyi bir davranış, çünkü saati sessizce atmak bilgi kaybıdır. FromDateTime ise saati hiç sormadan keser: 23:59'luk bir UTC değerini önce İstanbul saatine çevirmeden DateOnly'ye dönüştürürsen yanlış güne düşersin. .NET 10 ile ISOWeek class'ı DateOnly overload'ları aldı; haftalık depo raporlarında hafta numarası için ayrı bir DateTime kurman gerekmiyor.

Tarih Aritmetiği: Ay Sonu, Yaş ve TimeSpan

Girişteki vade sorusuna dönelim ve üç yolu yan yana koyalım. BusinessCalendar.EndOfMonth Bookstore kütüphanesindeki küçük bir yardımcı: ayın son gününü DateTime.DaysInMonth ile bulur.

public static DateOnly EndOfMonth(DateOnly day) =>
    new(day.Year, day.Month, DateTime.DaysInMonth(day.Year, day.Month));
DateTime invoiceDay = new(2027, 1, 31);
for (int i = 1; i <= 3; i++)
    Console.Write($"AddMonths({i}) = {invoiceDay.AddMonths(i):yyyy-MM-dd}   ");
Console.WriteLine();

DateTime chained = invoiceDay;
for (int i = 1; i <= 3; i++)
{
    chained = chained.AddMonths(1);
    Console.Write($"chained {i} = {chained:yyyy-MM-dd}   ");
}
Console.WriteLine();

DateOnly start = new(2027, 1, 31);
for (int i = 1; i <= 3; i++)
    Console.Write($"EndOfMonth(+{i}) = {BusinessCalendar.EndOfMonth(start.AddMonths(i)):yyyy-MM-dd}   ");
Console.WriteLine();

Console.WriteLine($"2028-02-29 AddYears(1) = {new DateOnly(2028, 2, 29).AddYears(1):yyyy-MM-dd}");
Console.WriteLine($"Days between 2026-11-14 and 2027-01-01 = {new DateOnly(2027, 1, 1).DayNumber - new DateOnly(2026, 11, 14).DayNumber}");
AddMonths(1) = 2027-02-28   AddMonths(2) = 2027-03-31   AddMonths(3) = 2027-04-30   
chained 1 = 2027-02-28   chained 2 = 2027-03-28   chained 3 = 2027-04-28   
EndOfMonth(+1) = 2027-02-28   EndOfMonth(+2) = 2027-03-31   EndOfMonth(+3) = 2027-04-30   
2028-02-29 AddYears(1) = 2029-02-28
Days between 2026-11-14 and 2027-01-01 = 48

Artık yıl da aynı şekilde davranıyor: 29 Şubat 2028'e bir yıl eklemek 28 Şubat 2029 verir. Başlangıç tarihinden her seferinde AddMonths(i) ile hesaplamak gününü korur; bir önceki sonuca bir ay eklemek ise ilk kısa ayda günü kaybeder ve bir daha geri getirmez. Taksit planı ya da abonelik yenilemesi gibi tekrarlayan tarihlerde her taksiti başlangıçtan hesapla. "Her ayın son günü" bir iş kuralıysa onu açıkça EndOfMonth ile ifade et; 31 Ocak'ın tesadüfen ay sonu olmasına güvenme. İki gün arasındaki farkı ise DayNumber farkıyla bulabilirsin; saat ve zone işin içine girmediği için DateOnly burada en temiz araç.

Yaş hesabı da benzer bir tuzak. Bookstore'un bazı kitapları 18 yaş sınırıyla satılıyor:

static int AgeOn(DateOnly birthDate, DateOnly today)
{
    int age = today.Year - birthDate.Year;
    if (birthDate > today.AddYears(-age))
        age--;
    return age;
}

DateOnly ada = new(2008, 11, 15);
DateOnly leapling = new(2008, 2, 29);
Console.WriteLine($"Ada on 2026-11-14     : {AgeOn(ada, new DateOnly(2026, 11, 14))}");
Console.WriteLine($"Ada on 2026-11-15     : {AgeOn(ada, new DateOnly(2026, 11, 15))}");
Console.WriteLine($"29 Feb on 2026-02-28  : {AgeOn(leapling, new DateOnly(2026, 2, 28))}");
Console.WriteLine($"29 Feb on 2026-03-01  : {AgeOn(leapling, new DateOnly(2026, 3, 1))}");
Console.WriteLine($"Naive days/365.25 on 2026-11-15: {(int)((new DateOnly(2026, 11, 15).DayNumber - ada.DayNumber) / 365.25)}");
Ada on 2026-11-14     : 17
Ada on 2026-11-15     : 18
29 Feb on 2026-02-28  : 17
29 Feb on 2026-03-01  : 18
Naive days/365.25 on 2026-11-15: 17

Gün sayısını 365,25'e bölen formül Ada'nın 18. doğum gününde hâlâ 17 diyor; bu 18 yılda yalnız dört artık gün var, formül ise dört buçuk varsayıyor. Takvim yılıyla çalışan karşılaştırma doğru sonucu veriyor. 29 Şubat'ta doğan biri artık olmayan yıllarda 1 Mart'ta bir yaş büyüyor; bazı ülkelerin mevzuatı 28 Şubat'ı esas alıyor. Bu kodun değil hukukun sorusu, ama kodda bir yerde açıkça karar verilmiş olmalı.

Son olarak TimeSpan. Bir kargonun yolda geçirdiği 26 saat 5 dakikayı biçimlendirelim:

TimeSpan transit = TimeSpan.FromHours(26) + TimeSpan.FromMinutes(5);
CultureInfo tr = CultureInfo.GetCultureInfo("tr-TR");

Console.WriteLine($"ToString()         : {transit}");
Console.WriteLine($"\"c\" / \"g\" (tr-TR)  : {transit.ToString("c")} / {transit.ToString("g", tr)}");
Console.WriteLine($"@\"hh\\:mm\"          : {transit.ToString(@"hh\:mm")}");
Console.WriteLine($"@\"d\\.hh\\:mm\"       : {transit.ToString(@"d\.hh\:mm")}");
Console.WriteLine($"TotalHours based   : {(int)transit.TotalHours}:{transit.Minutes:00}");
Console.WriteLine($"Hours / TotalHours : {transit.Hours} / {transit.TotalHours.ToString(Invariant)}");
Console.WriteLine($"Parse(\"1.02:05\")   : {TimeSpan.Parse("1.02:05", Invariant).TotalMinutes} minutes");
Console.WriteLine($"FromMilliseconds   : {TimeSpan.FromMilliseconds(1500)}");
ToString()         : 1.02:05:00
"c" / "g" (tr-TR)  : 1.02:05:00 / 1:2:05:00
@"hh\:mm"          : 02:05
@"d\.hh\:mm"       : 1.02:05
TotalHours based   : 26:05
Hours / TotalHours : 2 / 26.083333333333332
Parse("1.02:05")   : 1565 minutes
FromMilliseconds   : 00:00:01.5000000

hh toplam saati değil, gün çıkarıldıktan sonra kalan saat bileşenini yazar; 26 saatlik bir süre ekranda "02:05" görünür ve kimse fark etmez. Aynı ayrım property'lerde de var: Hours 2, TotalHours 26,08. Custom TimeSpan formatlarında : ve . gibi ayırıcıları ters bölü ile kaçırman gerekiyor; verbatim string (@"...") bunu okunur kılar. 24 saati aşabilecek süreleri TotalHours üzerinden kendin biçimlendir. "c" formatı culture'dan bağımsızdır ve log ya da dosya için uygundur; "g" ise culture'a göre değişir.

Bookstore: Teslim Tarihi = Onaydan 5 İş Günü Sonra

Kurumsal siparişlerde sözleşme şöyle: teslim tarihi onaydan 5 iş günü sonra; saat 17:00'den sonra verilen onay ertesi günden sayılır; hafta sonları ve resmî tatiller iş günü değildir. Hesap İstanbul saatine göre yapılır, sunucunun nerede çalıştığından bağımsız. İlk akla gelen kod DateTime.Now ile başlar ve test edilemez: testin sonucu çalıştırdığın güne ve saate bağlı olur. Bunun yerine "şimdi"yi dışarıdan alan bir class yazıyoruz:

public sealed class DeliveryDateCalculator(TimeProvider clock, BusinessCalendar calendar, TimeZoneInfo zone)
{
    public static readonly TimeOnly CutOff = new(17, 0);

    public DeliveryQuote QuoteForApprovalNow(int businessDays = 5)
    {
        DateTimeOffset approvedAt = TimeZoneInfo.ConvertTime(clock.GetUtcNow(), zone);
        DateOnly approvalDay = DateOnly.FromDateTime(approvedAt.DateTime);
        bool afterCutOff = TimeOnly.FromDateTime(approvedAt.DateTime) >= CutOff;

        DateOnly countFrom = afterCutOff ? approvalDay.AddDays(1) : approvalDay;
        DateOnly delivery = calendar.AddBusinessDays(countFrom, businessDays);
        return new DeliveryQuote(approvedAt, afterCutOff, delivery);
    }
}

public sealed record DeliveryQuote(DateTimeOffset ApprovedAt, bool AfterCutOff, DateOnly DeliveryDate);
public bool IsBusinessDay(DateOnly day) =>
    day.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday) && !_holidays.Contains(day);

public DateOnly AddBusinessDays(DateOnly start, int days)
{
    ArgumentOutOfRangeException.ThrowIfNegative(days);

    DateOnly day = start;
    while (days > 0)
    {
        day = day.AddDays(1);
        if (IsBusinessDay(day))
            days--;
    }
    return day;
}

TimeProvider .NET 8 ile gelen soyut saat. Üretimde TimeProvider.System verirsin; testte ise Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider ile zamanı kendin kurarsın. Bu class'ın async tarafı (timer'lar, Task.Delay, Advance ile tetiklenen bekleyen işler) C# Asenkron Programlama serisinin "C# Async ve .NET 8: TimeProvider, ConfigureAwaitOptions, Parallel.ForAsync ve CancelAsync" yazısında; burada yalnız senkron kullanımına, yani "şimdi kaç?" sorusuna bakıyoruz. Dikkat et, hesap GetLocalNow() kullanmıyor: o, makinenin (ya da fake provider'ın) yerel zone'una bağlı olurdu. Zone, sözleşmenin bir parçası olduğu için constructor'dan açıkça geliyor.

TimeZoneInfo istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
BusinessCalendar calendar = new([new DateOnly(2026, 10, 29)]);

// Testte "şimdi"yi biz belirliyoruz: 22 Ekim 2026 Perşembe, İstanbul 10:00.
FakeTimeProvider clock = new(new DateTimeOffset(2026, 10, 22, 7, 0, 0, TimeSpan.Zero));
DeliveryDateCalculator calculator = new(clock, calendar, istanbul);

Print(calculator.QuoteForApprovalNow());
clock.Advance(TimeSpan.FromHours(8) + TimeSpan.FromMinutes(10));
Print(calculator.QuoteForApprovalNow());
clock.SetUtcNow(new DateTimeOffset(2026, 10, 26, 6, 0, 0, TimeSpan.Zero));
Print(calculator.QuoteForApprovalNow());

static void Print(DeliveryQuote quote) =>
    Console.WriteLine($"approved {quote.ApprovedAt:ddd yyyy-MM-dd HH:mm zzz} after cut-off={quote.AfterCutOff,-5} -> delivery {quote.DeliveryDate:ddd yyyy-MM-dd}");
approved Thu 2026-10-22 10:00 +03:00 after cut-off=False -> delivery Fri 2026-10-30
approved Thu 2026-10-22 18:10 +03:00 after cut-off=True  -> delivery Mon 2026-11-02
approved Mon 2026-10-26 09:00 +03:00 after cut-off=False -> delivery Tue 2026-11-03

Perşembe sabahki onay cuma, pazartesi, salı, çarşamba ve (29 Ekim Cumhuriyet Bayramı'nı atlayıp) cumayı sayıyor: 30 Ekim. Aynı gün 18:10'daki onay saymaya cumadan başlıyor ve teslim 2 Kasım pazartesiye kayıyor. Pazartesi sabahki onay yine bayramı atlayıp 3 Kasım'da bitiyor. Üç durum da birkaç milisaniyede, gerçek saati beklemeden ve her çalıştırmada aynı sonuçla test edildi. Bir unit test'te bu satırlar birer assertion olur. Tatil listesini burada elle verdik; gerçekte bir configuration dosyasından ya da veritabanından gelir ve her yıl güncellenmesi gerekir. Arife günlerindeki yarım gün gibi kuralları da bu class'a eklemen gerekebilir.

Üretimde Dikkat

Time zone kuralları değişir. Ülkeler yaz saatini kaldırır, geri getirir, offset'lerini değiştirir; Türkiye'nin 2016'daki kararı da tz veritabanına bir güncelleme olarak girdi. Linux'ta .NET bu veriyi işletim sisteminin tzdata paketinden okur, Windows'ta Windows Update ile gelir. Container image'ını aylarca yeniden build etmezsen eski kurallarla çalışırsın; base image güncellemelerini takip etmenin bir nedeni de bu. Alpine gibi küçük image'larda tzdata hiç yüklü olmayabilir; o durumda FindSystemTimeZoneById exception fırlatır. Uygulama açılırken kullandığın zone'ları bir kez bulup sonucu sakla; hem kuralların var olduğunu erkenden doğrularsın hem de her istekte aramazsın (.NET 8'den beri dönen instance zaten cache'leniyor).

Gelecekteki bir yerel saati (gelecek ayki imza gününün saati gibi) UTC'ye çevirip saklamak, kurallar değişirse yanlış bir anı saklamak demektir. Bu durumda yerel saati ve IANA id'sini sakla, anı ihtiyaç anında hesapla; Çözüm Desenleri serisindeki zaman yazısı bu ayrımı veritabanı tarafıyla birlikte anlatıyor. Geçmiş anlar için ise UTC ya da DateTimeOffset yeterli.

Parse edilen her dış veri için formatı, culture'ı ve DateTimeStyles'ı açıkça ver. Statik Analiz serisinde anlatılan CA1305 kuralı (IFormatProvider almayan overload'ları işaretler) parse çağrılarında da geçerli; build'de uyarı olarak açmak bu hataların çoğunu kod review'a gelmeden yakalar.

Sık Düşülen Tuzaklar

DateTime.Now ile test yazmak. Test bugün geçer, cuma akşamı ya da yılbaşı gecesi kırılır. "Şimdi"yi TimeProvider ile dışarıdan al, testte FakeTimeProvider kullan.

Kind=Unspecified bir tarihi UTC sanmak. Offset'siz bir metin Unspecified olur; ToUniversalTime() onu yerel saat sayar ve sonuç sunucunun zone'una göre değişir. Metnin UTC olduğunu biliyorsan AssumeUniversal | AdjustToUniversal kullan, mümkünse DateTimeOffset ile çalış.

AddMonths(1)'i zincirlemek. 31 Ocak'tan başlayan zincir 28 Şubat'tan sonra her ayı 28'ine çeker. Tekrarlayan tarihleri başlangıçtan hesapla, ay sonu kuralını açıkça yaz.

Windows id'sini veritabanına yazmak. Linux'ta da çalışır ama HasIanaId=false bir zone'un Id'si başka sistemlerle paylaşılamaz ve bir bölgeye birden çok IANA zone'u karşılık gelir. IANA id'si sakla.

Sabit offset kodlamak. "Depo iki saat geride" bilgisi yılın bir kısmında doğrudur. Offset'i zone'dan ve tarihten hesapla.

Süreyi hh ile biçimlendirmek. 24 saati aşan süreler gün bileşenini kaybeder; TotalHours kullan.

Küçük Bir Deneme

★☆☆ date-zones örneğine "Asia/Kolkata" ve "America/Sao_Paulo" id'lerini ekle; her birinin Windows id'sini ve 2026-07-01 tarihindeki UTC offset'ini (GetUtcOffset) yazdır. Yaz saati uygulayan hangisi?

★★☆ BusinessCalendar'a tatil listesini bir CSV (değerleri ayırıcıyla sıralayan düz metin formatı) satırından ("2026-10-29;2027-01-01") DateOnly.ParseExact ile okuyan bir factory method ekle. Hatalı bir tarih geldiğinde hangi değerin hatalı olduğunu söyleyen bir exception fırlatsın.

★★★ DeliveryDateCalculator'ı Frankfurt deposu için genişlet: depo kendi tatil takvimini ve 15:00 Berlin saatine göre kendi cut-off'unu uygulasın. Onay 25 Ekim 2026'daki saat değişikliği gecesine denk gelecek şekilde FakeTimeProvider ile üç senaryo kur ve sonuçları kontrol et.

Zamandan sonra uygulamanın çalıştığı ortama geçiyoruz. Bir sonraki yazıda Environment ile ortam değişkenlerini ve platform bilgisini okumayı, Process ile harici bir aracı güvenli şekilde çalıştırıp çıktısını almayı, .NET 11'in yeni Process API'lerini ve süreyi Stopwatch ile doğru ölçmeyi göreceğiz.