Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Refactoring — #11

Refactoring Kataloğu 2: Encapsulate Variable/Record/Collection, Replace Primitive with Object, Replace Temp with Query ve Split Variable

Mert Özen 15 Kas 2026 16 dk 12 görüntülenme
Refactoring Kataloğu 2: Encapsulate Variable/Record/Collection, Replace Primitive with Object, Replace Temp with Query ve Split Variable

Veri kendi kurallarını koruyamayacak kadar açıksa çözüm onu kapsüllemektir: field'ı property'ye, Data Class'ı record'a, listeyi okuma görünümüne, türetilmiş değeri sorguya, string'i küçük bir tipe çevirmek. Yazıda Bookstore'un Order modeli dokuz refactoring'le dönüştürülüyor; IReadOnlyList'in cast'le delinmesi, class'la yazılan Isbn'in eşitlik hatası ve davranışı bilerek değiştiren iki fix commit'i gerçek çıktılarla gösteriliyor.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.404) ile, testler xUnit.net v3 4.0.3 ile derlenip denenmiştir.

Önce Tahmin Et: Bu Sipariş Neyi Basar?

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Ordering module'ünde sipariş modeli yıllar önce yazılmış ve o günden beri hep "bir alan daha" eklenerek büyümüş:

public static class OrderSettings
{
    public static int MaxQuantityPerLine = 10;
}

public class OrderItem
{
    public string Isbn = "";
    public string Title = "";
    public decimal UnitPrice;
    public int Quantity;
}

public class Address
{
    public string Street = "";
    public string City = "";
    public string PostalCode = "";
}

public class Customer
{
    public int Id;
    public string Name = "";
    public Address Address = new();
    public int LoyaltyPoints;
}

public class Order
{
    public List<OrderItem> Items { get; set; } = new();
    public decimal Total;
    public Customer Customer = new();
    public Address ShippingAddress = new();

    public void AddItem(OrderItem item)
    {
        if (item.Quantity > OrderSettings.MaxQuantityPerLine)
            throw new InvalidOperationException($"At most {OrderSettings.MaxQuantityPerLine} copies per line.");
        Items.Add(item);
        decimal lineTotal = item.UnitPrice * item.Quantity;
        Total += lineTotal;
    }

    public bool Contains(string isbn) => Items.Any(i => i.Isbn == isbn);

    public decimal DiscountedTotal(decimal rate)
    {
        decimal temp = Total * rate;
        temp = Total - temp;
        return Math.Round(temp, 2, MidpointRounding.AwayFromZero);
    }

    public string Summary()
    {
        List<string> lines = [];
        foreach (OrderItem item in Items)
        {
            decimal lineTotal = item.UnitPrice * item.Quantity;
            lines.Add($"{item.Quantity} x {item.Title} = {lineTotal}");
        }
        return string.Join(Environment.NewLine, lines);
    }
}

"Tekrar sipariş ver" özelliği eski bir siparişin satırlarını kopyalarken AddItem'i değil doğrudan Items.Add'i kullanıyor. Kitap ekranı ISBN'i (kitapların uluslararası standart numarası) tireli, arama ekranı tiresiz gönderiyor. Müşteri adres değiştirince profil ekranı Address.City'yi güncelliyor. Aşağıdaki program bu üç durumu art arda çalıştırıyor:

Order order = new() { Customer = ada, ShippingAddress = ada.Address };

order.AddItem(new OrderItem { Isbn = "978-0-441-01359-3", Title = "Dune", UnitPrice = 12.45m, Quantity = 1 });
order.Items.Add(new OrderItem { Isbn = "9780261103344", Title = "The Hobbit", UnitPrice = 149.90m, Quantity = 2 });
ada.Address.City = "Izmir";

Console.WriteLine($"Total: {order.Total}");
Console.WriteLine($"Contains 9780441013593: {order.Contains("9780441013593")}");
Console.WriteLine($"Ship to: {order.ShippingAddress.City}");

Siparişin toplamı kaç? Sipariş Dune'u içeriyor mu? Ankara'ya gönderilmiş bir sipariş, müşteri İzmir'e taşındıktan sonra nereye gönderilmiş görünür?

Cevap: Üç Sürpriz, Aynı Kök Neden

Total: 12.45
Contains 9780441013593: False
Ship to: Izmir

Toplam yalnız Dune'u sayıyor, çünkü Total bir field ve listeye AddItem dışından eklenen satır onu güncellemiyor. Aynı ISBN'in iki yazılışı iki farklı string. Siparişin teslimat adresi müşterinin adresiyle aynı object, müşteri taşınınca geçmiş sipariş de "taşınıyor". Üçünün kökü aynı: veri, kendi kurallarını koruyamayacak kadar açık. Bu yazının refactoring'leri tam bu sorunla ilgili: Encapsulate Variable, Encapsulate Record, Encapsulate Collection, Replace Temp with Query, Replace Derived Variable with Query, Split Variable, Replace Primitive with Object, Change Reference to Value ve Change Value to Reference. Adlar refactoring.com/catalog'daki gibi; açıklama ve örnekler bizim.

İşe yine testlerle başladım ve bu üç sürprizi de bugünkü davranış olarak testlere yazdım: Total_ignores_items_added_directly_to_the_list, Contains_compares_isbn_strings_as_written, Shipping_address_follows_the_customer_after_the_order. Hatalı davranışı neden kayda alıyoruz? Çünkü birazdan bazı adımlar bu davranışı değiştirecek ve bunun bilinçli olmasını istiyoruz. Birinci yazıdaki iki şapka kuralı burada çok somut: davranışı koruyan adım refactor:, değiştiren adım fix: commit'i oluyor ve ikisi asla aynı commit'e girmiyor.

Encapsulate Variable: Global Data ve Public Field'lar

Encapsulate Variable, her yerden okunup yazılabilen veriyi (Global Data ve Mutable Data smell'leri) tek bir erişim noktasının arkasına alır. Amaç değişkeni yok etmek değil, ona kimin, nasıl dokunduğunu görmek ve kontrol etmek. .NET'in kendi analyzer'ları bu smell'i tanıyor; dört kuralı .editorconfig'te uyarıya çevirince legacy kod şunu söyledi (satırlar kısaltıldı):

$ dotnet build --no-incremental
src/Bookstore.Ordering/Order.cs(5,23): warning CA2211: Non-constant fields should not be visible
src/Bookstore.Ordering/Order.cs(10,19): warning CA1051: Do not declare visible instance fields
src/Bookstore.Ordering/Order.cs(33,28): warning CA1002: Change 'List<OrderItem>' in 'Order.Items' to use 'Collection<T>', 'ReadOnlyCollection<T>' or 'KeyedCollection<K,V>'
src/Bookstore.Ordering/Order.cs(33,28): warning CA2227: Change 'Items' to be read-only by removing the property setter

CA1051 tek başına on dört kez çıktı. İlk hedef CA2211: MaxQuantityPerLine public static bir field ve test kodu dahil herkes onu değiştirebiliyor. Mekanik adımlar: field'ı saran bir property yaz, bütün okuma ve yazmaları property'ye çevir, field'ı private yap, test et. Property bir kez yerine oturunca doğrulamayı ekleyecek yer de belli:

public static class OrderSettings
{
    private static int s_maxQuantityPerLine = 10;

    public static int MaxQuantityPerLine
    {
        get => s_maxQuantityPerLine;
        set => s_maxQuantityPerLine = value >= 1
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value), value, "The limit must be at least 1.");
    }
}

Aynı adımı Customer ve Order'daki public field'lara da uyguladım: Id ve Name init, Address ve LoyaltyPoints set oldu. Bir uyarı: field'ı property'ye çevirmek kaynak kodda neredeyse hiçbir çağıranı kırmaz (ref ve out ile geçilen field'lar hariç), ama derlenmiş kodda kırar. Field ile property'nin IL'deki (derlenmiş .NET ara kodu) erişim şekli farklı; aynı paketi kullanan, yeniden derlenmemiş bir assembly MissingFieldException alır. Paket yayımlıyorsan bu bir breaking change'tir.

Static global veriyi property'ye almak bir ara adım; asıl hedef çoğu zaman onu IOptions<T> ile constructor'dan vermek. Bu da beşinci yazıda gördüğümüz constructor'ı parametreleme adımı.

Encapsulate Record: Data Class'tan record'a

OrderItem yalnız public field'lardan oluşan bir Data Class. Encapsulate Record, böyle bir veri yapısının alanlarını erişim kontrolü olan üyelerin arkasına alır. C#'ta bunun en kısa yolu bir record:

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

Bu son hâli; ilk commit'te Isbn hâlâ string'di ve LineTotal yoktu. Mekanik adımlar: record'u tanımla, object'i oluşturan her yeri object initializer'dan constructor'a çevir (testlerde bunu tek bir regex değiştirmesiyle yaptım), derle. Positional record'un property'leri init olduğu için satır oluşturulduktan sonra değiştirilemiyor. Visual Studio Code'un C# sayfası bu dönüşüm için "Convert class to record" adlı bir refactoring listeliyor.

Class'tan record'a geçmenin, kapsüllemenin ötesinde bir etkisi var: eşitlik. Record iki instance'ı alanları aynıysa eşit sayar; class ise referansa bakar. Items.Contains(item), Remove(item) ya da HashSet kullanan bir kod varsa davranışı değişir. Bizim kodda yoktu; senin kodunda varsa bu adım aynı zamanda bir Change Reference to Value'dur ve testle karşılanması gerekir.

Encapsulate Collection: Liste Değil, Okuma Görünümü

Encapsulate Collection'ın amacı, collection'ın sahibi olan class'ın, collection'a ne eklendiğini her zaman bilmesi. Mekanik adımlar: collection'ı private bir field'a al, dışarıya salt okunur bir görünüm ver, değiştirme işlemleri için method'lar sun (bizde zaten AddItem vardı), dışarıdan doğrudan değiştiren çağıranları method'a taşı. Property'nin tipini IReadOnlyList<OrderItem> yapınca derleyici, listeyi doğrudan değiştiren yeri hemen gösterdi:

tests/Bookstore.Ordering.Tests/OrderCharacterizationTests.cs(42,21): error CS1061: 'IReadOnlyList<OrderItem>' does not contain a definition for 'Add' and no accessible extension method 'Add' accepting a first argument of type 'IReadOnlyList<OrderItem>' could be found (are you missing a using directive or an assembly reference?)

Burada sık yapılan bir hata var. İlk denememde property, iç listeyi olduğu gibi döndürüyordu: public IReadOnlyList<OrderItem> Items => _items;. Tip salt okunur, ama object'in kendisi hâlâ bir List. Bunu sınamak için characterization testini cast'li bir eklemeye çevirdim ve test geçti; toplam yine 12,45'te kaldı. Yani kapı kapanmamıştı, sadece kolu sökülmüştü. Property _items.AsReadOnly() döndürünce aynı test kırmızıya döndü:

failed Bookstore.Ordering.Tests.OrderCharacterizationTests.Total_ignores_items_added_directly_to_the_list (0ms)
  Xunit.MicrosoftTestingPlatform.XunitException: System.InvalidCastException : Unable to cast object of type 'System.Collections.ObjectModel.ReadOnlyCollection`1[Bookstore.Ordering.OrderItem]' to type 'System.Collections.Generic.List`1[Bookstore.Ordering.OrderItem]'.

Bu kez kırmızı test iyi haber: dışarıdan değiştirme yolu gerçekten kapandı. Testi yeni davranışı anlatacak şekilde yeniden yazdım; son hâli cast'in exception fırlattığını ve toplamın değişmediğini doğruluyor:

private readonly List<OrderItem> _items = [];

public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

[Fact]
public void Items_cannot_be_changed_by_casting_to_List()
{
    Order order = new();
    order.AddItem(new OrderItem(Isbn.Parse("978-0-441-01359-3"), "Dune", 12.45m, 1));

    Assert.Throws<InvalidCastException>(() => ((List<OrderItem>)order.Items).Add(
        new OrderItem(Isbn.Parse("9780261103344"), "The Hobbit", 149.90m, 2)));
    Assert.Equal(12.45m, order.Total);
}

AsReadOnly() her çağrıda küçük bir wrapper object oluşturur; çok sık çağrılan bir döngüde sorun olursa wrapper'ı bir field'da tutmak yeter. Entity Framework Core'da (EF Core; .NET'in veritabanı erişim kütüphanesi) bu yapıyı _items backing field'ı üzerinden map etmek gerekir; nasıl yapıldığı DDD (Domain-Driven Design; iş alanını merkeze alan tasarım yaklaşımı) serisinin "Aggregate'leri EF Core 10 ile Persist Etmek: Backing Field, Owned/Complex Type ve Concurrency" yazısında.

Replace Temp with Query, Replace Derived Variable with Query ve Split Variable

item.UnitPrice * item.Quantity iki method'da geçici bir değişkende hesaplanıyordu. Replace Temp with Query, böyle bir değişkeni bir sorguya, yani bir method ya da property'ye çevirir: değişkenin bir kez atandığından emin ol, sağ tarafı bir property'ye çıkar, değişkenin kullanımlarını property ile değiştir, değişkeni sil. Sonuç OrderItem.LineTotal; artık satır tutarını bilen tek yer satırın kendisi. Değişken yalnız o method'da anlamlıysa Extract Variable yeter; birden fazla yerde aynı hesap tekrar ediyorsa sorgu daha iyi.

Replace Derived Variable with Query bu yazının asıl kazancı. Total, başka verilerden türetilen ama ayrıca saklanan bir değerdi; her değişiklikte elle güncellenmesi gerekiyordu ve biri bunu unuttuğu anda yanlış bir sayı gösteriyordu. Sorguya çevirince unutulacak bir şey kalmıyor:

public decimal Total => _items.Sum(i => i.LineTotal);

public decimal DiscountedTotal(decimal rate)
{
    decimal discount = Total * rate;
    decimal net = Total - discount;
    return Math.Round(net, 2, MidpointRounding.AwayFromZero);
}

Toplamı her okumada hesaplamak bir sepet ya da sipariş için ölçülebilir bir maliyet değil. Milyonlarca satırlı bir collection'da ya da sık çağrılan bir yolda ölçüp karar vermek gerekir. EF Core tarafında ise bir uyarı: Total artık veritabanında bir kolon değil, dolayısıyla Where(o => o.Total > 500) gibi bir sorgu SQL'e çevrilemez. Bu tuzağı on üçüncü yazıda Move Function ile birlikte gerçek çıktısıyla göreceğiz.

Aynı kodda Split Variable de var. Eski DiscountedTotal'da temp önce indirimi, sonra net tutarı tutuyordu. Bir değişkenin birden fazla anlam taşıması onu adlandırmayı imkânsız kılar; Split Variable her anlam için ayrı bir değişken açar: ilk atamayı yeni ve doğru adlı bir değişkene yap, sonraki kullanımları ona bağla, ikinci atama için aynısını tekrarla. Döngü sayaçları ve sum += ... gibi biriktiriciler bunun istisnası; onlar tek bir anlamı adım adım günceller. Parametreye yeniden atama da (rate = rate / 100) aynı smell'in bir türü.

Replace Primitive with Object: Isbn

ISBN bir string olarak dolaşıyor, kontrol hanesi hiç doğrulanmıyor ve aynı numaranın iki yazılışı iki farklı değer. Bu, Clean Code serisinin "Object mi Data Structure mı?" yazısında anlatılan Primitive Obsession. Replace Primitive with Object, primitive'i kendi kurallarını bilen küçük bir tiple değiştirir. Dördüncü yazıdaki Percentage da aynı refactoring'in bir örneğiydi.

Bu adımı bilerek iki commit'e böldüm. İlkinde Isbn yalnız string'i sarıyordu, normalleştirme ve doğrulama yoktu; testler aynı kaldı ve yeşildi, yani saf bir refactoring'di. İkincisinde tipe asıl işini verdim:

public sealed record Isbn
{
    private Isbn(string value) => Value = value;

    public string Value { get; }

    public static Isbn Parse(string text) =>
        TryParse(text, out Isbn? isbn) ? isbn : throw new FormatException($"'{text}' is not a valid ISBN-13.");

    public static bool TryParse(string? text, [NotNullWhen(true)] out Isbn? isbn)
    {
        isbn = null;
        string digits = (text ?? "").Replace("-", "").Replace(" ", "");
        if (digits.Length != 13 || !digits.All(char.IsAsciiDigit))
            return false;

        // ISBN-13 kontrol hanesi: 1 ve 3 ağırlıklı toplam
        int sum = 0;
        for (int i = 0; i < 12; i++)
            sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
        if ((10 - sum % 10) % 10 != digits[12] - '0')
            return false;

        isbn = new Isbn(digits);
        return true;
    }

    public override string ToString() => Value;
}

Testleri çalıştırınca beklediğim tek test kırmızıya döndü:

failed Bookstore.Ordering.Tests.OrderCharacterizationTests.Contains_compares_isbn_strings_as_written (0ms)
  Assert.False() Failure
  Expected: False
  Actual:   True

Davranış değişti, çünkü değişmesini istedik. Testin adını ve beklentisini yeni davranışa göre güncelledim, Isbn için ayrı bir test projesi açtım (tireli, boşluklu, yanlış kontrol haneli ve eksik haneli girdiler) ve commit'i fix: olarak attım. Bu ayrım review'da çok işe yarıyor: reviewer refactor: commit'lerinde "davranış aynı mı" sorusunu, fix: commit'inde "yeni davranış doğru mu" sorusunu soruyor.

Tipi record yapmamın nedeni eşitlik. Aynı tipi sıradan bir class olarak yazsaydık ne olacağını küçük bir file-based app ile (repo'da samples/equality.cs) gördüm:

IsbnClass a = new("9780441013593");
IsbnClass b = new("9780441013593");
IsbnRecord c = new("9780441013593");
IsbnRecord d = new("9780441013593");

Console.WriteLine($"class : a == b -> {a == b}, Equals -> {a.Equals(b)}");
Console.WriteLine($"record: c == d -> {c == d}, Equals -> {d.Equals(c)}");

HashSet<IsbnClass> seen = [a];
Console.WriteLine($"class in HashSet: {seen.Contains(b)}");

public sealed class IsbnClass(string value)
{
    public string Value { get; } = value;
}

public sealed record IsbnRecord(string Value);
$ dotnet run equality.cs
class : a == b -> False, Equals -> False
record: c == d -> True, Equals -> True
class in HashSet: False

Class'lı sürümle Contains artık hiçbir ISBN'i bulamazdı; string'le çalışan eski koddan da kötü bir sonuç. readonly record struct da bir seçenek, ama default(Isbn) doğrulamadan geçmiş, Value'su null olan bir değer üretir; bu yüzden burada sealed record class'ı seçtim.

Change Reference to Value ve Change Value to Reference

Üçüncü sürpriz, bir object'in paylaşılmasıyla ilgiliydi. Address bir referans: müşteri ve sipariş aynı object'i tutuyor ve biri değiştirince öbürü de değişiyor. Change Reference to Value, böyle bir object'i değiştirilemez bir değere çevirir: alanları init yap, değeri değiştirmek isteyen her yeri yeni bir değer oluşturacak şekilde yeniden yaz, eşitliği alanlara göre tanımla. C#'ta bunların hepsini record yapıyor. Address'i positional record yapınca derleyici, adresi yerinde değiştiren satırı buldu:

tests/Bookstore.Ordering.Tests/OrderCharacterizationTests.cs(63,9): error CS8852: Init-only property or indexer 'Address.City' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.

Adres değişikliği artık yeni bir değer oluşturuyor ve sipariş eski değerini koruyor:

ada.Address = ada.Address with { City = "Izmir" };

Assert.Equal("Ankara", order.ShippingAddress.City);
Assert.Equal("Izmir", ada.Address.City);

Bu adım da saf bir refactoring değil: geçmiş siparişin adresi artık değişmiyor. Gönderilmiş bir siparişin adresinin değişmesi bir hataydı, karar Product Owner'la verildi ve commit yine fix: oldu. Change Reference to Value'yu uygularken kodda "bir yerde değiştir, her yerde görünsün" davranışına güvenen bir kullanım var mı, önce onu ara; varsa ya o kullanım bir hatadır (bizdeki gibi) ya da bu object bir değer değil, gerçekten paylaşılan bir varlıktır.

İkinci durumun örneği Customer. Toplu sipariş içe aktarımı her satır için yeni bir Customer object'i oluşturuyordu; aynı müşterinin iki siparişine verilen sadakat puanları iki ayrı kopyada kalıyor, müşterinin toplamı hiçbir yerde 20 olmuyordu. Change Value to Reference bunun tersini yapar: aynı kimliğe sahip her şey için tek bir object. Bir repository object'leri kimliğe göre tutar ve her istekte aynı instance'ı döndürür:

public sealed class CustomerRepository
{
    private readonly Dictionary<int, Customer> _byId = [];

    public Customer Get(int id, string name)
    {
        if (!_byId.TryGetValue(id, out Customer? customer))
        {
            customer = new Customer { Id = id, Name = name };
            _byId.Add(id, customer);
        }
        return customer;
    }
}

Testte iki sipariş aynı müşteriyi Assert.Same ile paylaşıyor ve puan 20'ye çıkıyor. Gerçek uygulamada bu işi çoğu zaman EF Core'un change tracker'ı yapar: aynı DbContext içinde aynı anahtarla yüklenen entity'ler tek instance'tır. AsNoTracking() ile yüklediğinde ise her satır ayrı bir kopya olur, yani farkında olmadan Change Reference to Value uygulamış olursun.

Bütün Adımlar ve IDE Desteği

Yazının bütün adımları şu commit'lerde; fix: ile başlayan iki commit davranışı bilerek değiştiriyor, diğerleri değiştirmiyor:

$ git log --oneline
a61879a refactor: customers come from CustomerRepository (value to reference)
c10b1c7 fix: Address is an immutable value; orders keep the address they were shipped to
597b198 refactor: encapsulate Customer and Order fields as properties
09404cf fix: Isbn.Parse validates the check digit and normalizes hyphens
e7dab67 refactor: replace string ISBN with Isbn type (text kept as written)
003a834 refactor: split variable temp into discount and net
98e2415 refactor: replace derived Total field with a query
63446aa refactor: replace temp lineTotal with OrderItem.LineTotal
1b265be refactor: encapsulate Order.Items (read-only view, AddItem only)
ebd9792 refactor: encapsulate OrderItem as a record
2580684 refactor: encapsulate OrderSettings.MaxQuantityPerLine
cee55b2 build: enable CA1002, CA1051, CA2211, CA2227 as warnings
7202088 test: characterize Order (legacy, incl. surprising behavior)

IDE sütunu Microsoft Learn ve JetBrains belgelerine dayanıyor; IDE'leri bu yazı için çalıştırmadım. Analyzer sütunu ise yukarıdaki gerçek dotnet build çıktısından. Visual Studio'da Encapsulate field Ctrl+R, Ctrl+E ile açılıyor ve iki seçenek sunuyor: kullanımları property'ye çevirmek ya da field'ı kullanmaya devam etmek.

RefactoringSmellIDE / analyzerTersi
Encapsulate VariableGlobal Data, Mutable DataEncapsulate field (Visual Studio, Rider), CA2211, CA1051yok
Encapsulate RecordData ClassConvert class to record (Visual Studio Code belgesi)yok
Encapsulate CollectionMutable Dataelle; CA1002, CA2227 yeri gösteriryok
Replace Temp with QueryLong Functionelle (Extract Method + Inline Variable)Extract Variable
Replace Derived Variable with QueryMutable Dataelleyok
Split VariableMysterious Nameelleyok
Replace Primitive with ObjectPrimitive Obsessionelleyok
Change Reference to ValueMutable DataelleChange Value to Reference
Change Value to ReferenceDuplicated Code (aynı varlığın kopyaları)elleChange Reference to Value

Tablonun özeti: bu ailede IDE'nin otomatik yaptığı tek şey alanı property'ye çevirmek. Geri kalanı tasarım kararı içeriyor (hangi işlemler sunulacak, eşitlik neye göre, object paylaşılacak mı) ve bunları ancak testler güvenli kılıyor.

Sık Düşülen Tuzaklar

Encapsulate Collection'ı yalnız tip düzeyinde yapmak. IReadOnlyList döndürmek kapıyı kapatmaz; iç List'i döndürüyorsan cast'le değiştirilebilir. AsReadOnly(), bir kopya ya da immutable bir collection döndür ve bunu bir testle sabitle.

Value object'te eşitliği unutmak. Sıradan bir class olarak yazılan Isbn ile ==, Contains ve HashSet referansa bakar; yukarıdaki çıktıda gördüğün gibi aynı ISBN bulunamaz. record kullan ya da Equals ve GetHashCode'u birlikte yaz.

Refactoring ile düzeltmeyi aynı commit'e koymak. Bu yazıdaki iki adım davranışı değiştirdi. Normalleştirmeyi Isbn'i tanıttığım commit'e koysaydım, review'da "bu commit neden bir testi değiştiriyor" sorusunun cevabı diff'in içinde kaybolurdu.

Paylaşılan object'i değere çevirirken paylaşıma güvenen kodu atlamak. Change Reference to Value'dan önce object'in yerinde değiştirildiği bütün yerleri bul; CS8852 hataları bu listeyi derleyiciye çıkartmanın kolay yolu.

Field'ı property'ye çevirmenin binary uyumluluğu bozduğunu unutmak. Kendi solution'ında sorun yok; NuGet paketi yayımlıyorsan bu bir major sürüm değişikliği.

Küçük Bir Deneme

★☆☆ Kendi projende .editorconfig'e CA1002, CA1051, CA2211 ve CA2227'yi warning olarak ekle ve dotnet build --no-incremental çalıştır. En çok hangi kural çıktı? İlk Encapsulate adımını hangisiyle atardın?

★★☆ Bir entity'nin collection property'sini bul. İç listeyi döndürüp döndürmediğini cast'li bir testle sına; döndürüyorsa Encapsulate Collection uygula ve ekleme/çıkarma method'larını sun.

★★★ Kodunda string olarak dolaşan bir kimlik ya da kod seç (sipariş numarası, kupon kodu, vergi numarası). Replace Primitive with Object'i iki commit'te uygula: önce yalnız saran tip, sonra normalleştirme ve doğrulama. İkinci commit'te kaç test değişti ve her biri bir hata mıydı?

Veri artık kendi kurallarını koruyor, ama sorumluluklar hâlâ yanlış yerlerde toplanıyor: fatura hesabı üç farklı yerde, CSV (virgül ya da noktalı virgülle ayrılmış düz metin dosya) import'u tek bir döngüde hem ayrıştırıyor hem doğruluyor hem kaydediyor. Bir sonraki yazıda sorumlulukları gruplayan refactoring'lere geçiyoruz: Introduce Parameter Object, Preserve Whole Object, Combine Functions into Class ve Transform, Split Phase, Extract ve Inline Class, Hide Delegate, Remove Middle Man ve Substitute Algorithm.