Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Functional Programming Kavramları — #02

Immutability: Neden Değişmeyen Veri? C# record, readonly ve TypeScript Readonly ile Pratikte

Mert Özen 15 Kas 2026 13 dk 6 görüntülenme
Immutability: Neden Değişmeyen Veri? C# record, readonly ve TypeScript Readonly ile Pratikte

Immutability, bir değerin oluşturulduktan sonra değiştirilememesi ve her değişiklikte yeni değer üretilmesidir; aliasing, kaybolan cache key ve race condition sorunlarını aynı yerden çözer. Bookstore'un sepetini record, with ve ImmutableList ile yeniden yazıyor; shallow immutability'yi, kopyalama maliyetini, TypeScript readonly ve Object.freeze sınırlarını ve yerinde push edilen array'in Angular signal'ini neden güncellemediğini gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.402) ile, TypeScript örnekleri TypeScript 7.0.2, Node.js 26 ve Angular 22.2 (@angular/core 22.2.6) ile derlenip denenmiştir.

Önce Tahmin Et: Sipariş Verildikten Sonra Sepete Kitap Eklemek

İlk yazıda sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) ödeme kodunun, toplamı hesaplarken çağıranın sepetinden satır sildiğini gördük. Aynı codebase'in başka bir köşesinde sipariş, sepetten şöyle oluşturuluyor:

public class LegacyCart
{
    public List<LegacyCartLine> Lines { get; set; } = [];
}

public class LegacyOrder
{
    public List<LegacyCartLine> Lines { get; set; } = [];

    public static LegacyOrder PlaceFrom(LegacyCart cart) => new() { Lines = cart.Lines };
}

Müşteri siparişi veriyor, sonra alışverişe devam ediyor: sepete Middlemarch'ı ekliyor ve Dune'un adedini 3 yapıyor. Sipariş kaç satır ve siparişteki Dune adedi kaç?

LegacyCart cart = new() { Lines = SampleCarts.Legacy() };
LegacyOrder order = LegacyOrder.PlaceFrom(cart);

// Müşteri siparişten sonra alışverişe devam ediyor
cart.Lines.Add(new LegacyCartLine { Isbn = "9780141439549", Title = "Middlemarch", UnitPrice = 129.90m, Quantity = 1 });
cart.Lines[0].Quantity = 3;

Console.WriteLine($"order lines: {order.Lines.Count}, Dune quantity in order: {order.Lines[0].Quantity}");

Cevap: Aynı Listeye Bakan İki Object

$ dotnet run --project src/Bookstore.Fp.Demo -- 02-aliasing
order lines: 4, Dune quantity in order: 3

Sepet başlangıçta üç satırlıydı, sipariş de öyle olmalıydı. Ama PlaceFrom listeyi kopyalamadı, referansını verdi: sepet ve sipariş aynı List<LegacyCartLine> object'ine bakıyor. Sepete eklenen her satır siparişe de girdi; satır object'leri de ortak olduğu için Dune'un adedi siparişte de 3 oldu. Bu bug'ın adı aliasing: iki isim aynı değiştirilebilir veriyi gösteriyor ve birinin üzerinden yapılan değişiklik diğerini de etkiliyor. Kod review'da yakalanması zordur, çünkü hatalı satır PlaceFrom'da değil; sepeti değiştiren, belki başka bir ekibin yazdığı, tamamen masum görünen satırda ortaya çıkıyor.

Aynı problem cache key'lerinde de karşına çıkar. Fiyat cache'i bir record key ile tutuluyor; record'ın property'leri set olduğu için key sonradan değiştirilebiliyor:

public record PriceQuery
{
    public string Isbn { get; set; } = "";
    public string Currency { get; set; } = "TRY";
}
Dictionary<PriceQuery, decimal> priceCache = [];
PriceQuery query = new() { Isbn = "9780441013593", Currency = "TRY" };
priceCache[query] = 189.50m;

query.Currency = "EUR";

Console.WriteLine($"same object found: {priceCache.ContainsKey(query)}");
Console.WriteLine($"original key found: {priceCache.ContainsKey(new PriceQuery { Isbn = "9780441013593", Currency = "TRY" })}");
Console.WriteLine($"entries: {priceCache.Count}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 02-cache
same object found: False
original key found: False
entries: 1

Kayıt sözlükte duruyor ama artık hiçbir key ile bulunamıyor. Dictionary kaydı, eklendiği andaki hash code'a göre bir bucket'a yerleştirdi. Currency değişince hash code da değişti; aynı object ile arayınca başka bucket'a bakılıyor, orijinal değerlerle arayınca da doğru bucket'a gidiliyor ama oradaki key artık EUR dediği için eşitlik tutmuyor. Değeri değişebilen bir object'i key yapmak, o cache'e sessizce erişilemeyen kayıtlar bırakır.

Üçüncü maliyet concurrency'de. İki thread aynı listeyi aynı anda değiştirirse sonuç, işlemlerin hangi sırayla iç içe geçtiğine bağlı olur; Concurrency Teorisi serisinin "Race Condition ve Data Race: Check-Then-Act, Read-Modify-Write ve Atomicity" yazısı bunu ayrıntılı anlatıyor. Değişmeyen veri bu sorunların hepsini aynı yerden çözer: kimse değiştiremiyorsa paylaşmak güvenlidir. Lock gerekmez, kopyalamak gerekmez, key kaybolmaz.

Value Semantics: Değeri Neyse Odur

Immutability, bir değer oluşturulduktan sonra değiştirilememesi demek. "Değişiklik" gerektiğinde eski değer yerinde kalır, yenisi üretilir. Bookstore'da sepet satırı ve sepet şöyle oluyor:

public sealed record CartLine(string Isbn, string Title, decimal UnitPrice, int Quantity, bool IsEbook)
{
    public decimal LineTotal => UnitPrice * Quantity;
}

public sealed record Cart(ImmutableList<CartLine> Lines, string? CouponCode = null)
{
    public static Cart Empty { get; } = new(ImmutableList<CartLine>.Empty);

    public Cart Add(CartLine line) => this with { Lines = Lines.Add(line) };

    public Cart WithoutEmptyLines() => this with { Lines = Lines.RemoveAll(line => line.Quantity <= 0) };

    public Cart ChangeQuantity(string isbn, int quantity) =>
        this with { Lines = Lines.ConvertAll(line => line.Isbn == isbn ? line with { Quantity = quantity } : line) };
}

Positional record'ın property'leri init olarak üretilir; constructor'dan sonra atanamaz. with expression'ı mevcut değerin kopyasını, yalnız belirttiğin property'leri değiştirerek üretir. Buna nondestructive mutation denir: değiştiriyormuş gibi yazarsın ama eski değere dokunmazsın. Record'ın derleyicinin ürettiği member'ları ve with'in temelini C# OOP (nesne yönelimli programlama) serisinin "C# record ve record struct: Value Equality ve with Expression'ı" yazısında gördük; burada odak, bunu bir veri modelinin tamamına uygulamak.

ImmutableList<T>'nin Add, RemoveAll ve ConvertAll method'ları listeyi değiştirmez, yeni bir liste döndürür. Böylece Cart'ın her method'u yeni bir Cart döndürüyor:

Cart cart = Cart.Empty
    .Add(new CartLine("9780441013593", "Dune", 189.50m, 1, IsEbook: false))
    .Add(new CartLine("9780141439587", "Emma", 59.90m, 1, IsEbook: true))
    .Add(new CartLine("9780261103344", "The Hobbit", 149.90m, 0, IsEbook: false));

Cart cleaned = cart.WithoutEmptyLines();
Cart withCoupon = cleaned with { CouponCode = "AUTUMN10" };
Cart moreDune = withCoupon.ChangeQuantity("9780441013593", 3);

Console.WriteLine($"cart: {cart.Lines.Count} lines, cleaned: {cleaned.Lines.Count}, coupon on cleaned: {cleaned.CouponCode ?? "none"}");
Console.WriteLine($"Dune in withCoupon: {withCoupon.Lines[0].Quantity}, in moreDune: {moreDune.Lines[0].Quantity}");
Console.WriteLine($"shared Emma line: {ReferenceEquals(withCoupon.Lines[1], moreDune.Lines[1])}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 02-records
cart: 3 lines, cleaned: 2, coupon on cleaned: none
Dune in withCoupon: 1, in moreDune: 3
shared Emma line: True

Her ara değer kendi hâlinde duruyor: boş satırlar temizlendiğinde orijinal sepet üç satırlı kaldı, kupon eklemek temizlenmiş sepeti etkilemedi, Dune'un adedi yalnız son sepette değişti. Son satır da önemli: değişmeyen Emma satırı iki sepet arasında ortak. Immutable veride paylaşım güvenli olduğu için kopyalamak gerekmiyor; aynı object iki yerde birden kullanılabiliyor. Tahmin sorusundaki bug bu modelle yazılamaz: PlaceFrom sepetin listesini verse bile müşteri o listeyi değiştiremez, ancak yeni bir sepet üretebilir.

Para gibi küçük değerler için readonly record struct uygun: public readonly record struct Money(decimal Amount, string Currency);. Struct'ın bütün field'ları readonly olur ve kopyalanırken kimse ortak bir kopyayı değiştiremez.

Shallow ve Deep Immutability

Record yazmak tek başına immutability sağlamaz. Property'si List<T> olan bir record'a bakalım:

public sealed record ShallowCart(List<CartLine> Lines, string? CouponCode = null);
ShallowCart first = new([new CartLine("9780441013593", "Dune", 189.50m, 1, IsEbook: false)]);
ShallowCart second = first with { CouponCode = "AUTUMN10" };

second.Lines.Add(new CartLine("9780141439587", "Emma", 59.90m, 1, IsEbook: true));

Console.WriteLine($"first: {first.Lines.Count} lines, second: {second.Lines.Count} lines, same list: {ReferenceEquals(first.Lines, second.Lines)}");

IReadOnlyList<CartLine> readOnly = first.Lines;
((List<CartLine>)readOnly).Clear();
Console.WriteLine($"after cast and Clear: {first.Lines.Count} lines");
$ dotnet run --project src/Bookstore.Fp.Demo -- 02-shallow
first: 2 lines, second: 2 lines, same list: True
after cast and Clear: 0 lines

with bir shallow copy üretir: field'ların değerlerini kopyalar. Field bir referanssa referans kopyalanır, gösterdiği liste değil. ShallowCart'ın Lines property'si atanamaz ama gösterdiği liste değiştirilebilir; tahmin sorusundaki aliasing aynen geri geliyor. Buna shallow immutability denir. Deep immutability için zincirin her halkası değişmez olmalı: record'ın içindeki collection immutable, collection'daki element'ler de immutable record.

İkinci çıktı da sık yapılan bir hatayı gösteriyor: IReadOnlyList<T> bir garanti değil, bir görünüm. Seni yalnızca okuma method'larıyla sınırlar, ama arkadaki List<T> hâlâ oradadır ve bir cast ile değiştirilebilir. Read-only ile immutable arasındaki farkı C# Koleksiyonlar serisinin "C# Immutable Collection'lar ve Doğru Collection'ı Seçmek" yazısı ayrıntılı anlatıyor.

Immutable Collection'lar: Kopyalamanın Maliyeti

"Her değişiklikte yeni liste" fikri ilk duyulduğunda pahalı gelir. Maliyet, collection'ın nasıl kurulduğuna bağlı. 10.000 satırlık iki collection'a birer satır ekleyip ayrılan belleği ölçelim:

long before = GC.GetAllocatedBytesForCurrentThread();
ImmutableArray<CartLine> array2 = array.Add(line);
long arrayBytes = GC.GetAllocatedBytesForCurrentThread() - before;

before = GC.GetAllocatedBytesForCurrentThread();
ImmutableList<CartLine> list2 = list.Add(line);
long listBytes = GC.GetAllocatedBytesForCurrentThread() - before;

Console.WriteLine($"ImmutableArray.Add: {arrayBytes} bytes, ImmutableList.Add: {listBytes} bytes ({array2.Length}, {list2.Count})");
$ dotnet run --project src/Bookstore.Fp.Demo -- 02-cost
ImmutableArray.Add: 80032 bytes, ImmutableList.Add: 696 bytes (10001, 10001)

ImmutableArray<T> tek bir array'in üzerine kurulu; bir element eklemek 10.001 referanslık yeni bir array demek (yaklaşık 8 byte çarpı 10.001). ImmutableList<T> ise dengeli bir ağaç: ekleme yalnızca kökten değişen yere kadar olan düğümleri yeniden oluşturur, geri kalan düğümler eski ve yeni liste arasında ortak kalır. Bu tekniğe structural sharing denir ve functional dillerdeki kalıcı (persistent) veri yapılarının temelidir. Kural basit: çok okunup nadiren değişen küçük veride ImmutableArray<T> (okuması hızlı ve allocation'sız), sık değişen büyük veride ImmutableList<T>.

Bir kez kurulup uygulama boyunca yalnızca okunan lookup tabloları için .NET 8 ile gelen FrozenDictionary ve FrozenSet var. Kurulumu pahalı, okuması hızlı; kupon oranları gibi başlangıçta yüklenen configuration için uygun:

FrozenDictionary<string, decimal> couponRates = new Dictionary<string, decimal>
{
    ["AUTUMN10"] = 0.10m,
    ["WELCOME5"] = 0.05m
}.ToFrozenDictionary();
Console.WriteLine($"AUTUMN10: {couponRates["AUTUMN10"]}, coupons: {couponRates.Count}");
AUTUMN10: 0.10, coupons: 2

Bir ayrıntıya dikkat: record'ın value equality'si, property'lerinin Equals'ına dayanır. ImmutableList<T> value equality uygulamaz, referansları karşılaştırır. Aynı iki satırla ayrı ayrı kurulmuş iki sepet bu yüzden eşit çıkmıyor:

Cart rebuilt = Cart.Empty
    .Add(new CartLine("9780441013593", "Dune", 189.50m, 1, IsEbook: false))
    .Add(new CartLine("9780141439587", "Emma", 59.90m, 1, IsEbook: true));
Console.WriteLine($"lines equal: {cleaned.Lines.SequenceEqual(rebuilt.Lines)}, carts equal: {cleaned == rebuilt}");
lines equal: True, carts equal: False

Sepetleri değerine göre karşılaştırman gerekiyorsa (test, cache key) Equals'ı kendin yazmalı ya da SequenceEqual kullanmalısın.

TypeScript'te readonly, Readonly ve as const

TypeScript'te immutability tip sisteminin işi. readonly array'ler, Readonly<T> mapped type'ı ve as const derleme zamanında değiştirmeyi engeller:

import { sampleCart, type CartLine } from "../src/cart.ts";

const cart: readonly Readonly<CartLine>[] = sampleCart();
cart.push(cart[0]);
cart[0].quantity = 3;
const coupon: "AUTUMN10" | "WELCOME5" = "SPRING20";
$ npx tsc -p tsconfig.errors.json
errors/02-readonly-errors.ts(4,6): error TS2339: Property 'push' does not exist on type 'readonly Readonly<CartLine>[]'.
errors/02-readonly-errors.ts(5,9): error TS2540: Cannot assign to 'quantity' because it is a read-only property.
errors/02-readonly-errors.ts(6,7): error TS2322: Type '"SPRING20"' is not assignable to type '"AUTUMN10" | "WELCOME5"'.

Değiştirmek yerine yeni değer üretmek için ES2023'te gelen copy method'ları var: toSorted, toReversed, toSpliced ve with. Bunlar sort, reverse, splice ve index ataması yapan kodun kopya üreten karşılıkları ve readonly array'lerde de çalışıyor:

type ReadonlyCartLine = Readonly<CartLine>;

const couponCodes = ["AUTUMN10", "WELCOME5"] as const;
type CouponCode = (typeof couponCodes)[number];

function addLine(lines: readonly ReadonlyCartLine[], line: ReadonlyCartLine): readonly ReadonlyCartLine[] {
  return [...lines, line];
}

const cart: readonly ReadonlyCartLine[] = sampleCart();
const coupon: CouponCode = "AUTUMN10";
const withMiddlemarch = addLine(cart, { isbn: "9780141439549", title: "Middlemarch", unitPrice: 129.9, quantity: 1, isEbook: false });
console.log("lines:", cart.length, "->", withMiddlemarch.length, "coupon:", coupon);

// ES2023 copy method'ları
const byPrice = cart.toSorted((a, b) => a.unitPrice - b.unitPrice);
const withoutFirst = cart.toSpliced(0, 1);
const moreDune = cart.with(0, { ...cart[0], quantity: 3 });
console.log("first in cart:", cart[0].title, "| first by price:", byPrice[0].title);
console.log("withoutFirst:", withoutFirst.length, "| Dune in cart:", cart[0].quantity, "in moreDune:", moreDune[0].quantity);
$ node src/02-readonly.ts
lines: 3 -> 4 coupon: AUTUMN10
first in cart: Dune | first by price: Emma
withoutFirst: 2 | Dune in cart: 1 in moreDune: 3

as const, array'i readonly ["AUTUMN10", "WELCOME5"] tipine çevirir; (typeof couponCodes)[number] ifadesi de buradan kupon kodlarının union tipini türetir. Unutma: bunların hepsi derleme zamanında. Çalışma anında cart sıradan bir JavaScript array'i ve any ya da bir cast ile değiştirilebilir. Readonly<T> de shallow: iç içe object'lerin property'lerini readonly yapmaz.

Object.freeze ve Angular Signal'leri: Referans Değişmezse Hiçbir Şey Değişmez

Çalışma anında koruma için JavaScript'in Object.freeze'i var, ama o da shallow. İlk yazının ★★☆ alıştırmasının cevabıyla birlikte bakalım:

// Object.freeze shallow çalışır
const frozen = Object.freeze(sampleCart());
try {
  (frozen as CartLine[]).push(frozen[0]);
} catch (error) {
  console.log("push:", (error as Error).constructor.name);
}
frozen[0].quantity = 5;
console.log("nested change kept:", frozen[0].quantity);

// Signal'ler referansları Object.is ile karşılaştırır
const lines = signal<CartLine[]>(sampleCart());
const count = computed(() => lines().filter((line) => line.quantity > 0).length);
console.log("count:", count());

lines().push({ isbn: "9780141439549", title: "Middlemarch", unitPrice: 129.9, quantity: 1, isEbook: false });
lines.set(lines());
console.log("after push + set(same array):", count(), "| actual:", lines().filter((line) => line.quantity > 0).length);

lines.update((current) => [...current]);
console.log("after new reference:", count());
$ node src/02-freeze-and-signals.ts
push: TypeError
nested change kept: 5
count: 2
after push + set(same array): 2 | actual: 3
after new reference: 3

Dondurulmuş array'e push, strict mode'un varsayılan olduğu JavaScript module'lerinde TypeError fırlatıyor, ama array'in içindeki satır object'i dondurulmadığı için adet değişikliği sessizce kalıyor. Descriptor'ların ve deep freeze'in ayrıntısı JavaScript İleri serisinin "JavaScript Object Koruma ve Immutability: Property Descriptor, seal ve freeze" yazısında.

Signal kısmı Angular'da en sık görülen immutability bug'ı. Angular dokümantasyonunun belirttiği gibi signal'ler varsayılan olarak değerleri Object.is ile, yani referansla karşılaştırır. Array'e yerinde push edip aynı referansla set çağırınca signal "değer değişmedi" diye karar veriyor, computed eski sonucu döndürüyor: ekranda 2, gerçekte 3 satır. Yeni bir array referansı verince her şey düzeliyor. OnPush change detection kullanan component'lerde de kural aynı: input'un referansı değişmeden component yeniden render edilmez. Angular'da state'i immutable tutmak bir stil tercihi değil, framework'ün değişikliği algılama yolu.

JavaScript'te Gerçek Immutable Değerler: Records & Tuples'tan Composites'e

TypeScript'in readonly'si çalışma anında yok, Object.freeze shallow ve dondurulmuş iki object içerikleri aynı olsa bile === ile eşit değil. Bu boşluğu doldurmak için TC39'da (JavaScript standardını geliştiren komite) yıllarca Records & Tuples önerisi tartışıldı: #{ } ve #[ ] syntax'ı ile deep immutable ve değerine göre karşılaştırılan primitive'ler. Öneri Nisan 2025'te geri çekildi ve repository'si arşivlendi; tarayıcı motorlarını geliştiren ekipler, içeriğe göre === karşılaştırması yapan yeni bir primitive tipin maliyetine itiraz etmişti. Yerine aynı ekipten Composites önerisi geldi. Composites yeni bir primitive değil, Composite({...}) ile oluşturulan, dondurulmuş ve aynı içerikte olanların aynı object'e denk geldiği (interning) object'ler; böylece ===, Map key'i ve Set için doğrudan kullanılabiliyor. Bu yazının yazıldığı gün (Kasım 2026) önerinin GitHub sayfası Stage 2'de olduğunu söylüyor; yani syntax ve davranış hâlâ değişebilir, production'da kullanılacak bir şey değil.

Sık Düşülen Tuzaklar

set property'li record'ı immutable sanmak. record value equality ve with verir, immutability'yi vermez. PriceQuery örneğindeki gibi get; set; property'li bir record, değerine göre karşılaştırılan ama değiştirilebilen en tehlikeli kombinasyon: hash code'u değişen bir cache key.

Büyük immutable yapılarda kopyalama maliyetini unutmak. Her with, record'ın bütün field'larını kopyalar; her ImmutableArray.Add bütün array'i. Bir döngü içinde binlerce kez with ya da ImmutableArray.Add çağırıyorsan ya ImmutableList gibi structural sharing yapan bir yapı kullan ya da builder ile (ImmutableArray.CreateBuilder) toplu değiştirip sonunda bir kez immutable hâle getir. Değişiklik local ve dışarıya sızmıyorsa method'un içinde mutable bir List<T> kullanıp sonucu immutable döndürmek de functional: dışarıdan bakan kimse farkı göremez.

Entity Framework Core (EF Core; .NET'in veritabanı erişim kütüphanesi) entity'lerini record yapmak. EF Core'un change tracker'ı entity'leri instance kimliğiyle izler ve property'lerini yerinde değiştirerek günceller. with ile üretilen yeni instance izlenmez; değişiklik ya kaydedilmez ya da aynı key'li ikinci bir instance yüzünden exception alırsın. Value equality de identity resolution ile çatışır. Entity'ler class olarak kalsın; immutability'yi value object'lerde ve DTO'larda (Data Transfer Object; katmanlar arasında veri taşıyan sade tip) kullan. Value object'lerin EF Core'da complex type olarak saklanması DDD (Domain-Driven Design; iş alanını merkeze alan tasarım yaklaşımı) serisinin "Entity ve Value Object: Identity, Equality ve C# record ile Immutable Modeller" yazısında.

Immutable collection içeren record'ın eşitliğine güvenmek. Yukarıdaki carts equal: False çıktısı test assertion'larında ve cache key'lerinde sürpriz yaratır. ImmutableList<T> ya da ImmutableArray<T> property'si olan bir record'ı karşılaştırıyorsan eşitliği açıkça tanımla.

Küçük Bir Deneme

★☆☆ LegacyOrder.PlaceFrom'u, satırların kopyasını alacak şekilde düzelt (cart.Lines.Select(...) ile yeni satır object'leri). Tahmin sorusunun çıktısı nasıl değişiyor? Bu düzeltme sepetin ileride başka bir yerde aliasing'e yol açmasını engelliyor mu?

★☆☆ PriceQuery'yi positional bir readonly record struct yap ve query.Currency = "EUR" satırının derleyici hatasını oku.

★★☆ Cart'a Equals(Cart? other) ve GetHashCode() yazarak iki sepetin satırlarına göre eşit sayılmasını sağla; carts equal çıktısının True olduğunu doğrula.

★★★ Angular örneğinde signal<CartLine[]>(sampleCart(), { equal: ... }) ile satır satır karşılaştıran bir equality fonksiyonu ver. Yerinde push + set yine çalışmıyor; neden? (İpucu: equality fonksiyonu aynı array'i kendisiyle karşılaştırıyor.)

Bir sonraki yazıda değişmeyen verinin üzerinde çalışan fonksiyonlara geçiyoruz: pure function ve side effect. LegacyCheckout'taki DateTime.Now'ı, console yazısını ve static field'ı hesaplamadan ayıracak, indirim kuralını aynı girdiye her zaman aynı sonucu veren bir fonksiyona çevirip xUnit ile test edeceğiz.