Mert Özen Her Satırda Daha İleri
Backend LINQ — #14

Gerçek Veriyle LINQ: JSON Dosyasından Okunan Siparişlerle Senaryolar

Mert Özen 7 Kas 2026 12 dk 1 görüntülenme
Gerçek Veriyle LINQ: JSON Dosyasından Okunan Siparişlerle Senaryolar

System.Text.Json ile dosyadan okunan siparişler üzerinde LINQ: büyük-küçük harf tuzağı ve JsonSerializerDefaults.Web, veriyi doğrulamak (tekrar eden, müşterisi olmayan, hatalı adetli kayıtlar), aylık ciro, en çok satanlar, GroupJoin ile müşteri raporu, JSON'a geri yazmak ve DeserializeAsyncEnumerable ile stream ederek okumak.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Bu yazıda LINQ'i örnek listeler yerine bir dosyadan okunan sipariş verisiyle kullanacağız. Dosya, bir e-ticaret sisteminden alınmış tipik bir JSON dökümü; ilk sipariş şöyle görünüyor:

{ "id": 1001, "customerId": 1, "orderedAt": "2026-08-03T10:15:00Z", "items": [
  { "isbn": "978-0132350884", "title": "Clean Code", "quantity": 2, "unitPrice": 37.90 },
  { "isbn": "978-0134757599", "title": "Refactoring", "quantity": 1, "unitPrice": 47.00 } ] }

Dosyayı en kısa yoldan okuyalım. Program derleniyor, hiçbir uyarı yok ve çalışırken exception da atmıyor. Sence üç satırda ne yazar?

using System.Text.Json;

string json = File.ReadAllText("orders.json");
List<Order> orders = JsonSerializer.Deserialize<List<Order>>(json)!;

Console.WriteLine($"Orders: {orders.Count}");
Console.WriteLine($"First id: {orders[0].Id}, customer: {orders[0].CustomerId}");
Console.WriteLine($"Items is null: {orders[0].Items is null}");

public record OrderItem(string Isbn, string Title, int Quantity, decimal UnitPrice);
public record Order(int Id, int CustomerId, DateTime OrderedAt, List<OrderItem> Items);

Cevap: Sessizce Boş Gelen Field'lar

Orders: 10
First id: 0, customer: 0
Items is null: True

On siparişin onu da okundu ama hepsinin Id'si 0, Items'ı da null. System.Text.Json varsayılan olarak property adlarını büyük-küçük harf duyarlı eşleştirir. JSON'daki id, C# tarafındaki Id ile eşleşmedi; serializer da eşleşmeyen field'ları hata vermeden atladı ve record'un constructor'ına her parametre için varsayılan değeri verdi. Üstelik Items nullable olmayan bir List<OrderItem> olarak tanımlı. Nullable reference types bir compile time uyarı sistemidir; runtime'da JSON'dan gelen null'ı durdurmaz. İlk o.Items.Sum(...) çağrısında NullReferenceException alırdın, hem de dosyayı okuduğun yerden çok uzakta.

Çözüm tek satır: new JsonSerializerOptions(JsonSerializerDefaults.Web). Bu ön ayar, web API'lerinin alışkanlığına uyar: property adlarını camelCase yazar ve okurken büyük-küçük harfe bakmaz. ASP.NET Core da kendi JSON ayarlarında aynı varsayılanları kullanır. Bu ayarı bir kez oluşturup her yerde aynı instance'ı kullan; her çağrıda yeni JsonSerializerOptions oluşturmak, serializer'ın tip bilgisini her seferinde yeniden hazırlamasına yol açar.

Veri ve Model

Yazının geri kalanı tek bir Program.cs üzerinden ilerliyor. Aşağıdaki bölümleri sırayla alt alta koyarsan çalışan programı elde edersin; tip tanımları top-level statement'lardan sonra, dosyanın en sonunda durur. Kullanacağımız sipariş dosyası:

[
  { "id": 1001, "customerId": 1, "orderedAt": "2026-08-03T10:15:00Z", "items": [
    { "isbn": "978-0132350884", "title": "Clean Code", "quantity": 2, "unitPrice": 37.90 },
    { "isbn": "978-0134757599", "title": "Refactoring", "quantity": 1, "unitPrice": 47.00 } ] },
  { "id": 1002, "customerId": 2, "orderedAt": "2026-08-14T16:40:00Z", "items": [
    { "isbn": "978-0321125217", "title": "Domain-Driven Design", "quantity": 1, "unitPrice": 54.00 } ] },
  { "id": 1003, "customerId": 3, "orderedAt": "2026-08-31T22:30:00Z", "items": [
    { "isbn": "978-0135957059", "title": "The Pragmatic Programmer", "quantity": 1, "unitPrice": 42.50 },
    { "isbn": "978-0132350884", "title": "Clean Code", "quantity": 1, "unitPrice": 37.90 } ] },
  { "id": 1004, "customerId": 1, "orderedAt": "2026-09-05T09:00:00Z", "items": [
    { "isbn": "978-0201633610", "title": "Design Patterns", "quantity": 1, "unitPrice": 49.90 } ] },
  { "id": 1005, "customerId": 5, "orderedAt": "2026-09-18T12:20:00Z", "items": [
    { "isbn": "978-0132350884", "title": "Clean Code", "quantity": 3, "unitPrice": 37.90 } ] },
  { "id": 1006, "customerId": 99, "orderedAt": "2026-09-21T08:05:00Z", "items": [
    { "isbn": "978-0135957059", "title": "The Pragmatic Programmer", "quantity": 1, "unitPrice": 42.50 } ] },
  { "id": 1007, "customerId": 2, "orderedAt": "2026-10-02T14:00:00Z", "items": [
    { "isbn": "978-0134757599", "title": "Refactoring", "quantity": 2, "unitPrice": 47.00 },
    { "isbn": "978-0321125217", "title": "Domain-Driven Design", "quantity": -1, "unitPrice": 54.00 } ] },
  { "id": 1008, "customerId": 3, "orderedAt": "2026-10-11T19:45:00Z", "items": [
    { "isbn": "978-0321125217", "title": "Domain-Driven Design", "quantity": 1, "unitPrice": 54.00 },
    { "isbn": "978-0201633610", "title": "Design Patterns", "quantity": 1, "unitPrice": 49.90 } ] },
  { "id": 1008, "customerId": 3, "orderedAt": "2026-10-11T19:45:00Z", "items": [
    { "isbn": "978-0321125217", "title": "Domain-Driven Design", "quantity": 1, "unitPrice": 54.00 },
    { "isbn": "978-0201633610", "title": "Design Patterns", "quantity": 1, "unitPrice": 49.90 } ] },
  { "id": 1009, "customerId": 5, "orderedAt": "2026-10-27T11:10:00Z", "items": [
    { "isbn": "978-0135957059", "title": "The Pragmatic Programmer", "quantity": 2, "unitPrice": 42.50 } ] }
]

Bu dosyada bilerek bırakılmış üç kusur var; birazdan LINQ ile bulacağız. Müşteri dosyası daha kısa:

[
  { "id": 1, "name": "Ada Lovelace", "city": "London" },
  { "id": 2, "name": "Alan Turing", "city": "Manchester" },
  { "id": 3, "name": "Grace Hopper", "city": "New York" },
  { "id": 4, "name": "Linus Torvalds", "city": "Helsinki" },
  { "id": 5, "name": "Margaret Hamilton", "city": "Boston" }
]

Tipler record. Total, JSON'da olmayan, siparişin kalemlerinden hesaplanan bir property; serializer okurken onu görmezden gelir:

public record Customer(int Id, string Name, string City);

public record OrderItem(string Isbn, string Title, int Quantity, decimal UnitPrice);

public record Order(int Id, int CustomerId, DateTime OrderedAt, List<OrderItem> Items)
{
    public decimal Total => Items.Sum(i => i.Quantity * i.UnitPrice);
}

public record CustomerReport(string Name, string City, int Orders, decimal Revenue);

Okuma kısmı async. DeserializeAsync dosyayı stream üzerinden okur; dosyanın tamamını önce bir string'e almaz. Top-level statement'larda await doğrudan kullanılabildiği için ayrıca bir Main yazmıyoruz:

using System.Globalization;
using System.Text.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);

List<Order> orders = await LoadAsync<Order>("orders.json", options);
List<Customer> customers = await LoadAsync<Customer>("customers.json", options);

Console.WriteLine($"Loaded {orders.Count} orders and {customers.Count} customers");
Console.WriteLine($"First order: {orders[0].Id}, {orders[0].Items.Count} items, {orders[0].OrderedAt:u}");

static async Task<List<T>> LoadAsync<T>(string path, JsonSerializerOptions options)
{
    await using FileStream stream = File.OpenRead(path);
    return await JsonSerializer.DeserializeAsync<List<T>>(stream, options) ?? new List<T>();
}
Loaded 10 orders and 5 customers
First order: 1001, 2 items, 2026-08-03 10:15:00Z

CultureInfo.CurrentCulture'ı bilerek InvariantCulture yaptık. Raporlardaki sayılar N2 ile biçimleniyor ve tr-TR kültüründe 257.20 yerine 257,20 yazılırdı. Çıktının makineye göre değişmesini istemiyorsan kültürü açıkça seç.

Önce Veriyi Doğrula

Gerçek veriyle çalışmanın ilk kuralı, verinin temiz olduğunu varsaymamak. Bir export iki kez çalışmış olabilir, silinmiş bir müşteriye ait sipariş kalmış olabilir, bir iade satırı eksi adetle girilmiş olabilir. Rapor üretmeden önce LINQ ile bunları ayıklayalım:

HashSet<int> customerIds = customers.Select(c => c.Id).ToHashSet();

List<int> duplicateIds = orders
    .GroupBy(o => o.Id)
    .Where(g => g.Count() > 1)
    .Select(g => g.Key)
    .ToList();

List<int> unknownCustomer = orders
    .Where(o => !customerIds.Contains(o.CustomerId))
    .Select(o => o.Id)
    .ToList();

List<int> badQuantity = orders
    .Where(o => o.Items.Any(i => i.Quantity <= 0))
    .Select(o => o.Id)
    .ToList();

List<Order> valid = orders
    .DistinctBy(o => o.Id)
    .Where(o => customerIds.Contains(o.CustomerId))
    .Where(o => o.Items.All(i => i.Quantity > 0))
    .ToList();

Console.WriteLine($"Duplicate ids: {string.Join(", ", duplicateIds)}");
Console.WriteLine($"Unknown customer: {string.Join(", ", unknownCustomer)}");
Console.WriteLine($"Bad quantity: {string.Join(", ", badQuantity)}");
Console.WriteLine($"Valid orders: {valid.Count} of {orders.Count}");
Duplicate ids: 1008
Unknown customer: 1006
Bad quantity: 1007
Valid orders: 7 of 10

Müşteri Id'lerini önce bir HashSet<int>'e aldık. Contains her sipariş için çağrılacak ve bir HashSet'te bu işlem sabit sürede biter; aynı kontrolü customers.Any(c => c.Id == o.CustomerId) ile yazsaydın her sipariş için müşteri listesini baştan sona tarardın. Tekrar eden siparişi GroupBy ile bulduk, temizlerken de .NET 6 ile gelen DistinctBy'ı kullandık: aynı Id'den ilkini tutar, gerisini atar.

Hatalı kayıtları sadece atmadık, listeledik. Gerçek bir sistemde bu listeler bir log'a ya da bir uyarı e-postasına gider. Sessizce atılan veri, aylar sonra "rapordaki ciro neden muhasebeyle tutmuyor" sorusu olarak geri döner. Bundan sonraki bütün raporlar valid listesini kullanıyor; ToList() ile materialize ettik ki doğrulama sorgusu her raporda yeniden çalışmasın.

Aylık Ciro

Yöneticinin ilk soracağı soru: hangi ay ne kadar sattık?

var monthly = valid
    .GroupBy(o => new { o.OrderedAt.Year, o.OrderedAt.Month })
    .OrderBy(g => g.Key.Year)
    .ThenBy(g => g.Key.Month)
    .Select(g => new
    {
        Month = $"{g.Key.Year}-{g.Key.Month:D2}",
        Orders = g.Count(),
        Revenue = g.Sum(o => o.Total),
    });

foreach (var m in monthly)
    Console.WriteLine($"{m.Month}  {m.Orders} orders  {m.Revenue,7:N2}");
2026-08  3 orders   257.20
2026-09  2 orders   163.60
2026-10  2 orders   188.90

Gruplama key'i iki field'lı bir anonymous type. Anonymous type'lar değer eşitliğiyle karşılaştırıldığı için aynı yıl ve aynı aya ait siparişler aynı gruba düşüyor. Sıralamayı key üzerinden yaptık, metin üzerinden değil; bu örnekte "2026-08" gibi string'ler de doğru sıralanırdı ama sayısal field'larla sıralamak biçimlendirmeye bağımlı olmaz. Para hesapları decimal ile yapılıyor; JSON'daki 37.90 decimal'a birebir okunur, double'daki yuvarlama sürprizleri burada yok.

En Çok Satan Kitaplar

Siparişler kitapları değil kalemleri taşıyor. Önce bütün kalemleri tek bir sequence'a açmamız gerekiyor; bu iş SelectMany'nin:

var topSellers = valid
    .SelectMany(o => o.Items)
    .GroupBy(i => i.Isbn)
    .Select(g => new
    {
        Title = g.First().Title,
        Quantity = g.Sum(i => i.Quantity),
        Revenue = g.Sum(i => i.Quantity * i.UnitPrice),
    })
    .OrderByDescending(x => x.Quantity)
    .ThenByDescending(x => x.Revenue)
    .Take(3);

foreach (var book in topSellers)
    Console.WriteLine($"{book.Title,-26} {book.Quantity}  {book.Revenue,7:N2}");
Clean Code                 6   227.40
The Pragmatic Programmer   3   127.50
Domain-Driven Design       2   108.00

Gruplamayı ISBN'e göre yaptık, başlığa göre değil. Başlık metni sistemden sisteme değişebilir; aynı kitap bir kayıtta "Domain-Driven Design", diğerinde "Domain Driven Design" olarak gelebilir. Kimlik olarak değişmeyen field'ı kullan, ekranda göstermek için başlığı grubun ilk element'inden al.

ThenByDescending satırı süs değil. Domain-Driven Design ve Design Patterns ikişer adet satıldı. İkinci bir sıralama kuralı olmasaydı üçüncü sıraya hangisinin gireceği, siparişlerin dosyadaki sırasına bağlı kalırdı. Eşitlik durumunda ne olacağını kendin belirle, yoksa raporun bir gün sebepsiz yere değişir.

Müşteri Raporu ve Left Join

Müşteri başına sipariş sayısı ve ciro. Burada dikkat edilecek nokta, hiç sipariş vermemiş müşterilerin de raporda görünmesi:

List<CustomerReport> report = customers
    .GroupJoin(valid,
        c => c.Id,
        o => o.CustomerId,
        (c, customerOrders) => new CustomerReport(
            c.Name,
            c.City,
            customerOrders.Count(),
            customerOrders.Sum(o => o.Total)))
    .OrderByDescending(r => r.Revenue)
    .ThenBy(r => r.Name)
    .ToList();

foreach (CustomerReport r in report)
    Console.WriteLine($"{r.Name,-18} {r.City,-11} {r.Orders}  {r.Revenue,7:N2}");
Margaret Hamilton  Boston      2   198.70
Grace Hopper       New York    2   184.30
Ada Lovelace       London      2   172.70
Alan Turing        Manchester  1    54.00
Linus Torvalds     Helsinki    0     0.00

Join kullansaydık Linus Torvalds listede olmazdı, çünkü inner join yalnızca iki tarafta da karşılığı olan kayıtları getirir. GroupJoin ise her müşteri için, boş da olsa bir sipariş grubu üretir. Join yazısında GroupJoin ve DefaultIfEmpty ile kurduğumuz left join pattern'ının raporlamadaki karşılığı bu. Boş grupta Count() 0, Sum da 0 döndürüyor.

Raporu başka bir sisteme vermek için JSON'a geri yazalım. İlk iki müşteri yeterli:

var writeOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };

await using (FileStream output = File.Create("customer-report.json"))
    await JsonSerializer.SerializeAsync(output, report.Take(2), writeOptions);

Console.WriteLine(File.ReadAllText("customer-report.json"));
[
  {
    "name": "Margaret Hamilton",
    "city": "Boston",
    "orders": 2,
    "revenue": 198.70
  },
  {
    "name": "Grace Hopper",
    "city": "New York",
    "orders": 2,
    "revenue": 184.30
  }
]

Aynı JsonSerializerDefaults.Web ön ayarı yazarken property adlarını camelCase'e çevirdi. decimal değerler sayı olarak, sondaki sıfırlarıyla yazıldı. report.Take(2) bir IEnumerable; serializer onu enumerate ederek JSON array'ine çevirdi, ayrıca ToList() çağırmamıza gerek kalmadı.

Büyük Dosyalar: Stream Ederek Okumak

DeserializeAsync<List<Order>> bütün siparişleri belleğe alır. On kayıt için sorun değil, ama birkaç gigabaytlık bir export'ta öyle değil. .NET 6 ile gelen JsonSerializer.DeserializeAsyncEnumerable, en dıştaki JSON array'ini element element okur ve her siparişi okunduğu anda verir:

using System.Globalization;
using System.Text.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);

int count = 0;
decimal largest = 0;

await using FileStream stream = File.OpenRead("orders.json");
await foreach (Order? order in JsonSerializer.DeserializeAsyncEnumerable<Order>(stream, options))
{
    if (order is null)
        continue;

    count++;
    largest = Math.Max(largest, order.Total);
}

Console.WriteLine($"Streamed {count} orders, largest: {largest}");

public record OrderItem(string Isbn, string Title, int Quantity, decimal UnitPrice);

public record Order(int Id, int CustomerId, DateTime OrderedAt, List<OrderItem> Items)
{
    public decimal Total => Items.Sum(i => i.Quantity * i.UnitPrice);
}
Streamed 10 orders, largest: 122.80

Bu sefer doğrulama yapmadan bütün kayıtları saydık; amaç bellekte tek seferde yalnızca bir siparişin durduğunu göstermek. Dönüş tipi IAsyncEnumerable<Order?> olduğu için await foreach ile dolaşıyoruz. Peki üzerinde LINQ yazmak istersen?

using System.Text.Json;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);

await using FileStream stream = File.OpenRead("orders.json");
IAsyncEnumerable<Order?> orders = JsonSerializer.DeserializeAsyncEnumerable<Order>(stream, options);

int bigOrders = orders.Where(o => o!.Total > 100m).Count();

public record OrderItem(string Isbn, string Title, int Quantity, decimal UnitPrice);

public record Order(int Id, int CustomerId, DateTime OrderedAt, List<OrderItem> Items)
{
    public decimal Total => Items.Sum(i => i.Quantity * i.UnitPrice);
}
Program.cs(8,24): error CS0411: The type arguments for method 'ImmutableArrayExtensions.Where<T>(ImmutableArray<T>, Func<T, bool>)' cannot be inferred from the usage. Try specifying the type arguments explicitly.

Hata mesajı yanıltıcı: derleyici, yine System.Linq namespace'inde duran ImmutableArrayExtensions.Where'den bahsediyor. Asıl sebep, .NET 6'nın IAsyncEnumerable<T> için LINQ operatörleri içermemesi. Ya await foreach içinde kendi döngünü yazarsın ya da topluluğun System.Linq.Async paketini eklersin. .NET 10 bu operatörleri BCL'e aldı; serinin .NET 10 yazısında bunu ve paketle yaşanan isim çakışmasını göreceğiz.

Sık Düşülen Tuzaklar

Ayı UTC'ye göre hesaplamak. JSON'daki tarihler Z ile bitiyor, yani UTC. 1003 numaralı sipariş UTC'ye göre 31 Ağustos'ta verilmiş, ama İstanbul'daki bir mağaza için 1 Eylül'e düşer:

using System.Text.Json;

DateTime orderedAt = JsonSerializer.Deserialize<DateTime>("\"2026-08-31T22:30:00Z\"");
TimeZoneInfo istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
DateTime local = TimeZoneInfo.ConvertTimeFromUtc(orderedAt, istanbul);

Console.WriteLine($"Kind: {orderedAt.Kind}");
Console.WriteLine($"UTC month: {orderedAt:yyyy-MM}, Istanbul month: {local:yyyy-MM}");
Kind: Utc
UTC month: 2026-08, Istanbul month: 2026-09

Aylık raporumuz bu siparişi Ağustos'a yazdı. Hangisinin doğru olduğu bir iş kararı; önemli olan bunu bilinçli vermek. Raporu bir mağazanın yerel takvimine göre istiyorsan gruplamadan önce tarihi o saat dilimine çevir. .NET 6'dan beri Europe/Istanbul gibi IANA saat dilimi adları, ICU desteği olan Windows sürümlerinde de tanınır.

Doğrulanmamış veriden rapor üretmek. Doğrulama adımını atlasaydık tekrar eden 1008 numaralı sipariş ekim cirosunu iki kez sayardı, eksi adetli kalem de ciroyu sessizce düşürürdü. LINQ sorgun ne kadar doğru olursa olsun, girdisi kadar doğru sonuç verir.

Okuma sorgusunu tekrar tekrar çalıştırmak. valid'i materialize etmeseydik her rapor doğrulama zincirini, yani HashSet aramalarını ve DistinctBy'ı baştan çalıştırırdı. Önceki yazıda ölçtüğümüz çoklu enumeration maliyeti, gerçek veride tam olarak böyle ortaya çıkar.

Küçük Bir Deneme

★☆☆ Şehir bazında ciro raporu yaz: müşterileri siparişlerle eşleştir, şehre göre grupla, ciroya göre azalan sırala. New York kaç sipariş vermiş?

★★☆ Aylık raporu İstanbul saatine göre yeniden üret. Hangi ayın cirosu değişiyor ve ne kadar?

★★★ Doğrulama sonucunu bir record'da topla (ValidationReport: tekrar eden, müşterisi bilinmeyen ve hatalı adetli sipariş Id'leri) ve customer-report.json'un yanına validation.json olarak yaz. Sonra DeserializeAsyncEnumerable ile, siparişleri belleğe toplamadan aynı doğrulamayı tek geçişte yapmayı dene. Hangi kural tek geçişte yapılamıyor?

Bu yazıyla LINQ serisinin C# 10 ve .NET 6 üzerine kurulu kemik yazıları tamamlandı. Sıradaki üç yazı sürüm yazısı. İlkinde .NET 7 ve .NET 8'e bakıyoruz: key selector istemeyen Order(), selector'süz ToDictionary() ve bu iki sürümün LINQ performansına getirdikleri. Her başlıkta önce .NET 6'da nasıl yazdığımızı, sonra yeni sürümün neyi değiştirdiğini göreceğiz.