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

Refactoring Kataloğu 3: Introduce Parameter Object, Preserve Whole Object, Combine Functions into Class/Transform, Split Phase, Extract/Inline Class ve Hide Delegate

Mert Özen 15 Kas 2026 12 dk 10 görüntülenme
Refactoring Kataloğu 3: Introduce Parameter Object, Preserve Whole Object, Combine Functions into Class/Transform, Split Phase, Extract/Inline Class ve Hide Delegate

Sorumlulukları gruplayan refactoring'ler, birlikte dolaşan veriyi bir tipe, birlikte çalışan fonksiyonları bir class'a ve iç içe geçmiş aşamaları ayrı class'lara toplar. Yazıda Bookstore'un CSV import'u Split Phase ile parse ve doğrulama aşamalarına bölünüyor; transform'un with ile tutarsızlaşması ve Substitute Algorithm'in eşdeğerlik testinde eski ayrıştırıcının İngilizce biçimde yazılmış 1,234.50'yi 1,2345 TL okuduğunun ortaya çıkması 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 ve Verify.XunitV3 32.0.0 ile derlenip denenmiştir.

Önce Tahmin Et: İki Ayrıştırıcı Aynı Sonucu Verir mi?

Katalog bloğunun bu üçüncü yazısı sorumlulukları gruplayan refactoring'leri ele alıyor: Introduce Parameter Object, Preserve Whole Object, Hide Delegate, Remove Middle Man, Combine Functions into Class, Combine Functions into Transform, Split Phase, Extract Class, Inline Class ve Substitute Algorithm. Adlar refactoring.com/catalog'daki gibi; açıklama ve örnekler bizim. Hepsini sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Ordering module'ünde, kurumsal müşterilerin toplu sipariş yüklediği CSV (noktalı virgülle ayrılmış düz metin dosya) import'u, fatura hesabı ve satış raporu üzerinde uyguluyoruz.

Import tek bir döngüde satırı bölüyor, alanları ayırıyor, her alanı doğruluyor ve hata mesajı yazıyor. Fiyat alanı Türkçe biçimde geliyor ("1.149,90") ve onu elle yazılmış küçük bir class çeviriyor:

public static class TurkishDecimal
{
    // "1.234,50" -> 1234.50
    public static bool TryParse(string text, out decimal value)
    {
        string normalized = text.Replace(".", "").Replace(",", ".");
        return decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.InvariantCulture, out value);
    }
}

Ekipten biri bunu .NET'in kendi kültür desteğiyle değiştirmek istiyor: decimal.TryParse(text, NumberStyles.Number, CultureInfo.GetCultureInfo("tr-TR"), out value). Katalogdaki adıyla bu bir Substitute Algorithm: aynı sonucu veren, daha açık bir algoritmayı eskisinin yerine koymak. Ondalık ayıracı virgül, binlik ayıracı nokta olan, başında artı ya da eksi işareti, etrafında boşluk olan 19 farklı girdi düşün. İki algoritma kaçında aynı sonucu verir?

Introduce Parameter Object ve Preserve Whole Object

İşe yine characterization testleriyle başladım: import'un sonucu bir Verify snapshot'ında, fatura, rapor ve kargo ücreti birkaç Assert ile. Snapshot'ta iki tuhaf satır var ve bugünkü davranış olarak onaylandı: "59.90" fiyatı 5990 olarak, "1,234.50" fiyatı 1,23450 olarak okunuyor. Bunlara yazının sonunda döneceğiz.

Satış raporunun iki method'u da bir tarih aralığı alıyor: Revenue(orders, DateOnly from, DateOnly to) ve OrderCount(orders, from, to). Hep birlikte dolaşan bu iki parametre Clean Code serisinin "Code Smell Kataloğu" yazısındaki Data Clumps'ın tipik örneği. Introduce Parameter Object onları bir tipe toplar: yeni tipi oluştur, method'lara bu tipte bir parametre ekle (üçüncü yazıdaki parallel change ile ya da çağıranların hepsi solution içindeyse tek adımda), çağıranları taşı, eski parametreleri sil. Asıl kazanç ikinci adımda geliyor: aralık kontrolünü çağıranlardan alıp tipin içine taşımak.

public sealed record DateRange(DateOnly From, DateOnly To)
{
    public bool Contains(DateOnly date) => date >= From && date <= To;
}

public static decimal Revenue(IEnumerable<Order> orders, DateRange period) =>
    orders.Where(o => period.Contains(o.PlacedOn)).Sum(o => o.Total);

Bu adımda From'un To'dan sonra olmasını reddeden bir doğrulama da eklemek istedim, ama ters bir aralık eski kodda sessizce sıfır döndürüyordu; exception fırlatmak davranış değişikliği olurdu. O yüzden doğrulama bu commit'e girmedi; ayrı bir fix: commit'i olarak karar verilecek bir iş. Üçüncü yazıda gördüğümüz PriceRequest de aynı refactoring'in bir örneğiydi.

Preserve Whole Object bunun yakın akrabası. Kargo ücreti ShippingQuote.FeeFor(order.Total, order.ShippingAddress.City) ile çağrılıyordu: çağıran, siparişin içinden iki değeri söküp gönderiyor. Method'a siparişin kendisini vermek, yarın kargo kuralı siparişin başka bir alanına (ağırlık, e-kitap olup olmadığı) bakmaya başladığında imzayı tekrar değiştirmemizi önler:

-    public static decimal FeeFor(decimal orderTotal, string city)
+    public static decimal FeeFor(Order order)
-        if (orderTotal >= 300m)
+        if (order.Total >= 300m)
-        return MetroCities.Contains(city) ? 49.90m : 64.90m;
+        return MetroCities.Contains(order.ShippingAddress.City) ? 49.90m : 64.90m;

Bedeli de var: ShippingQuote artık Order'a bağımlı. Kargo hesabı Ordering'den bağımsız bir module'de olsaydı (ya da farklı yerlerden gelen değerlerle de çağrılsaydı) iki değeri ayrı ayrı almak doğru tercih olurdu. Preserve Whole Object'in tersi, katalogda adı geçen ayrı bir refactoring değil; parametreyi tekrar değerlere açmak bir Change Function Declaration.

Hide Delegate ve Remove Middle Man

Bu iki refactoring birbirinin tersi ve aynı class'ta ikisini birden uyguladım. Raporlar müşteriye göre gruplarken o.Customer.Id yazıyordu; Order'ın içindeki Customer'ı tanıyan bir zincir (Message Chains smell'i). Hide Delegate, zinciri sahibinin üzerinde bir üyeyle gizler: public int CustomerId => Customer.Id;. Rapor artık yalnız Order'ı tanıyor. DDD (Domain-Driven Design; iş alanını merkeze alan tasarım yaklaşımı) serisinin "Aggregate Tasarımı: Consistency Boundary, Küçük Aggregate'ler ve Id ile Referans" yazısındaki fikre de bir adım: bir gün Order yalnız CustomerId tutacaksa, çağıranlar buna şimdiden hazır.

Aynı class'ta tersi bir sorun da vardı. Order zamanla CustomerName, CustomerLoyaltyPoints ve CustomerAddress gibi, yalnız Customer'a yönlenen property'ler toplamıştı. Bunları kullanan tek yer sadakat raporuydu ve o rapor siparişle değil, müşteriyle ilgileniyordu. Order burada bir Middle Man: iş yapmadan yalnız iletiyor. Remove Middle Man iletici üyeleri kaldırır ve çağıranı asıl object'le konuşturur:

public static class LoyaltyReport
{
    public static string Line(Customer customer) =>
        $"{customer.Name} ({customer.Id}): {customer.LoyaltyPoints} points, {customer.Address.City}";
}

Hangisinin doğru olduğunu belirleyen soru şu: çağıran ara object'in arkasındakini tanımalı mı? Siparişleri müşteriye göre sayan bir rapor siparişle çalışır, müşterinin iç yapısını bilmesi gerekmez. Sadakat raporu ise zaten müşteriyle ilgili. Law of Demeter'i (bir object'in yalnız yakın komşularıyla konuşması kuralı) her zincire mekanik olarak uygulamak, her class'ı onlarca iletici üyeyle dolu bir middle man'e çevirir; kuralın SOLID (beş temel tasarım ilkesinin baş harflerinden oluşan kısaltma) ve Design Principles serisinin "Coupling ve Cohesion" yazısındaki anlatımı da bunu bir kural değil, bir coupling ölçüsü olarak ele alıyor.

Combine Functions into Class mı, Transform mu?

Fatura hesabı dört static fonksiyondu ve hepsi aynı üç argümanı alıyordu:

public static class InvoiceFunctions
{
    public static decimal BaseAmount(Order order) => order.Total;

    public static decimal DiscountAmount(Order order, decimal discountRate) =>
        Math.Round(order.Total * discountRate, 2, MidpointRounding.AwayFromZero);

    public static decimal VatAmount(Order order, decimal discountRate, decimal vatRate) =>
        Math.Round((order.Total - DiscountAmount(order, discountRate)) * vatRate, 2, MidpointRounding.AwayFromZero);

    public static decimal InvoiceTotal(Order order, decimal discountRate, decimal vatRate) =>
        BaseAmount(order) - DiscountAmount(order, discountRate) + VatAmount(order, discountRate, vatRate);
}

Aynı veri üzerinde çalışan fonksiyon grubu için katalogda iki seçenek var. Combine Functions into Class, ortak veriyi bir class'ın state'i yapar ve fonksiyonları onun üyelerine çevirir. Primary constructor ile çok kısa:

public sealed class InvoiceCalculator(Order order, decimal discountRate, decimal vatRate)
{
    public decimal BaseAmount => order.Total;

    public decimal Discount => Round(BaseAmount * discountRate);

    public decimal Vat => Round((BaseAmount - Discount) * vatRate);

    public decimal Total => BaseAmount - Discount + Vat;

    private static decimal Round(decimal amount) => Math.Round(amount, 2, MidpointRounding.AwayFromZero);
}

Combine Functions into Transform ise kaynak veriyi alıp türetilmiş değerleri de içeren yeni bir veri döndüren tek bir fonksiyon yazar; C#'ta doğal karşılığı immutable bir record. İkisini karşılaştırmak için küçük bir file-based app yazdım (repo'da samples/transform.cs): önce faturayı iki yolla hesaplıyor, sonra siparişe bir kitap ekliyor, en sonda da transform'un sonucunu with ile kopyalayıp indirimi sıfırlıyor.

InvoiceData data = InvoiceTransform.Enrich(order, discountRate: 0.10m, vatRate: 0.10m);
InvoiceCalculator calculator = new(order, discountRate: 0.10m, vatRate: 0.10m);

order.AddItem(new OrderItem(Isbn.Parse("9780441013593"), "Dune", 12.45m, 1));

Console.WriteLine($"transform : {data.Total}");
Console.WriteLine($"calculator: {calculator.Total}");

InvoiceData noDiscount = data with { Discount = 0m };
Console.WriteLine($"with      : discount {noDiscount.Discount}, total {noDiscount.Total}");

public sealed record InvoiceData(decimal BaseAmount, decimal Discount, decimal Vat, decimal Total);
$ dotnet run transform.cs
transform : 296.80
calculator: 309.12
with      : discount 0, total 296.80

İki fark var. Transform'un sonucu bir fotoğraf: sipariş değişince eskir. Class ise her okumada hesaplar. Ve with türetilmiş alanları yeniden hesaplamaz; indirimi sıfırlanmış ama toplamı aynı kalmış, kendi içinde tutarsız bir fatura ortaya çıktı. Kural basit: kaynak veri sonradan değişebiliyorsa class. Veri bir pipeline'da bir kez akıyor ve değişmiyorsa (bir rapor satırı, bir dışa aktarım kaydı) transform daha sade; o zaman da türetilmiş alanları with ile değil, transform'u yeniden çağırarak üret. Bookstore'da sipariş, fatura kesilene kadar değişebildiği için InvoiceCalculator'ı seçtim.

Split Phase ve Extract Class

Import'a dönelim. Tek döngü iki farklı işi iç içe yapıyordu: metni yapıya çevirmek (satırlar, alanlar, satır numaraları) ve yapıya iş kurallarını uygulamak (müşteri numarası, ISBN (kitapların uluslararası standart numarası), adet sınırı, fiyat). Split Phase bu tür bir kodu, aralarında yalnız bir ara veri yapısının dolaştığı iki ardışık aşamaya böler. Clean Code serisindeki Divergent Change smell'inin tipik cevabı: CSV biçimi değiştiğinde (yeni bir kolon, tırnaklı alanlar) yalnız birinci aşama, iş kuralı değiştiğinde yalnız ikinci aşama değişir.

12-split-phase-tr.png

Mekanik adımlar: ikinci aşamanın kodunu kendi method'una çıkar; iki aşamanın paylaştığı veriyi bulup bir ara veri yapısına koy (burada CsvRow(int Number, string[] Fields)); birinci aşamayı da kendi method'una çıkarıp bu yapıyı döndürmesini sağla; ikinci aşama yalnız bu yapıyı alsın. İlk commit'te iki aşama aynı class'ın iki private method'uydu ve snapshot değişmedi. İkinci commit bir Extract Class: her aşama kendi class'ına taşındı ve import ince bir birleştiriciye dönüştü.

public class OrderCsvImporter
{
    public ImportResult Import(string csv) => OrderRowValidator.Validate(OrderCsvParser.Parse(csv));
}

public static class OrderCsvParser
{
    public static IReadOnlyList<CsvRow> Parse(string csv)
    {
        List<CsvRow> rows = [];
        int row = 0;
        foreach (string raw in csv.Split('\n'))
        {
            row++;
            if (row == 1 || raw.Trim() == "")
                continue;
            rows.Add(new CsvRow(row, raw.Trim().Split(';')));
        }
        return rows;
    }
}

Extract Class, bir class iki farklı nedenle değişiyorsa ya da alanlarının bir kısmı hep birlikte kullanılıyorsa (Large Class ve Data Clumps) uygulanır: yeni class'ı aç, ilgili alanları ve method'ları Move Field ve Move Function ile tek tek taşı (bu ikisi bir sonraki yazının konusu), her taşımadan sonra test et. Kazanç hemen geldi: parser artık iş kurallarından bağımsız test edilebiliyor. Başlığı ve boş satırları atladığını, satır numarasını koruduğunu doğrulayan test dört satır.

Substitute Algorithm: Önce Eşdeğerliği Sına

Substitute Algorithm'in mekanik adımları kısa: eski algoritmayı tek bir fonksiyonun arkasına al, yenisini yaz, ikisini aynı girdilerle çalıştırıp sonuçları karşılaştır, aynıysa eskisini sil. Zor olan, "aynı girdiler"in yeterince kapsamlı olması. Bu yüzden ayrı bir test yazdım: 19 girdiyi iki algoritmaya da veriyor ve hem başarı durumunu hem değeri (scale dahil, string olarak) karşılaştırıyor.

[Theory]
[InlineData("12,45")]
[InlineData("1.149,90")]
[InlineData("59.90")]
// ... toplam 19 girdi
[InlineData("1,234.50")]
[InlineData("0,1.5")]
public void New_algorithm_gives_the_same_result(string text)
{
    bool oldOk = TurkishDecimal.TryParse(text, out decimal oldValue);
    bool newOk = decimal.TryParse(text, NumberStyles.Number, Turkish, out decimal newValue);

    Assert.Equal((oldOk, oldValue.ToString(CultureInfo.InvariantCulture)), (newOk, newValue.ToString(CultureInfo.InvariantCulture)));
}

Cevap: 17 Girdi Aynı, İkisi Farklı ve Eskisi Yanlıştı

failed Bookstore.Ordering.Tests.PriceParsingEquivalenceTests.New_algorithm_gives_the_same_result(text: "0,1.5") (1ms)
  Assert.Equal() Failure: Values differ
  Expected: Tuple (True, "0.15")
  Actual:   Tuple (False, "0")
failed Bookstore.Ordering.Tests.PriceParsingEquivalenceTests.New_algorithm_gives_the_same_result(text: "1,234.50") (0ms)
  Assert.Equal() Failure: Values differ
  Expected: Tuple (True, "1.23450")
  Actual:   Tuple (False, "0")

On yedi girdide iki algoritma birebir aynı; "12,", ",5", "+7", etrafında boşluk olan ve binlik ayıraçlı girdiler dahil. "59.90"'ı ikisi de 5990 okuyor, çünkü Türkçe biçimde nokta binlik ayıracı. Farklı olan iki girdi ise ilginç: İngilizce biçimde yazılmış "1,234.50"'yi eski kod sessizce 1,2345 TL'ye çeviriyor, yeni kod reddediyor. Excel'i İngilizce olan bir okulun yükleyeceği bir dosyada bin TL'lik bir kalem, bir lira yirmi üç kuruşa düşerdi.

Yani bu Substitute Algorithm saf bir refactoring olamaz: iki girdide davranış değişiyor ve değişen davranış eskisinden doğru. On birinci yazıdaki gibi bunu bir fix: commit'i yaptım: yeni algoritma, eşdeğerlik testinin silinmesi ve snapshot'ın güncellenmesi aynı commit'te. Snapshot diff'i değişikliği tam olarak gösteriyor: 1,23450 TL'lik satır kayboldu, yerine bir hata satırı geldi. Diff'in son kısmı:

<     Row 9: price 'abc' is not a number.
---
>     Row 9: price 'abc' is not a number.,
>     Row 10: price '1,234.50' is not a number.

Bir uyarı: CultureInfo.GetCultureInfo("tr-TR") işletim sisteminin kültür verisine dayanır. Uygulama InvariantGlobalization açık çalışıyorsa (küçük container imajlarında sık görülür) bu satır exception fırlatır. Bunu ayrı bir küçük programla (samples/invariant-culture.cs) denedim: CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. Böyle bir ortamda kendi NumberFormatInfo'nu (ondalık ayıracı virgül, binlik ayıracı nokta) kurmak daha sağlam.

Substitute Algorithm'den sonra TurkishDecimal class'ı tek satırlık bir method'a indi ve tek kullanıcısı validator'dı. Inline Class, Extract Class'ın tersi: artık yeterince iş yapmayan bir class'ın üyelerini onu kullanan class'a taşır ve class'ı siler. Ayrıştırma OrderRowValidator'ın içine, kültür bilgisi de onun private bir field'ına taşındı.

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

IDE sütunu Microsoft Learn ve JetBrains belgelerine dayanıyor; IDE'leri bu yazı için çalıştırmadım. Bu ailede otomasyon zayıf, çünkü adımların çoğu bir tasarım kararı içeriyor.

RefactoringSmellIDETersi
Introduce Parameter ObjectData Clumps, Long Parameter ListRider: Transform Parameters; Visual Studio: elleyok
Preserve Whole ObjectLong Parameter Listelle (Change Signature yardımcı olur)Change Function Declaration
Hide DelegateMessage ChainselleRemove Middle Man
Remove Middle ManMiddle ManelleHide Delegate
Combine Functions into ClassShotgun Surgeryelleyok
Combine Functions into TransformShotgun Surgeryelleyok
Split PhaseDivergent Changeelle (Extract Method ile)yok
Extract ClassLarge Class, Data ClumpsRider: Extract Class; Visual Studio: elleInline Class
Inline ClassLazy ElementRider: Inline ClassExtract Class
Substitute Algorithmyok (okunurluk, doğruluk)elle; eşdeğerlik testi şartyok
$ git log --oneline
c27679e refactor: inline class TurkishDecimal into OrderRowValidator
3c57edf fix: parse prices with tr-TR culture; reject English-formatted prices
dc113bb refactor: extract OrderCsvParser and OrderRowValidator classes
1dc28fb refactor: split CSV import into parse and validate phases
2e8bbdf refactor: combine invoice functions into InvoiceCalculator
f280b1f refactor: remove middle man (customer profile forwarders on Order)
817055b refactor: hide delegate with Order.CustomerId
05da370 refactor: preserve whole object in ShippingQuote.FeeFor
f580083 refactor: move the range check into DateRange.Contains
3cc2758 refactor: introduce DateRange parameter object
342e26a test: characterize CSV import, invoice functions, reports and shipping quote

Sık Düşülen Tuzaklar

Hide Delegate'i her zincire uygulamak. Her a.B.C için a'ya bir iletici üye eklemek, bir süre sonra iş yapmayan, yalnız ileten class'lar üretir. Önce çağıranın arkadaki object'i tanıması gerekip gerekmediğini sor; Law of Demeter bir uyarı işareti, bir kural listesi değil.

Transform'un sonucunu with ile değiştirmek. Türetilmiş alanlar kaynakla birlikte güncellenmez; yukarıdaki çıktıda indirimi sıfırlanmış ama toplamı değişmemiş bir fatura gördük.

Substitute Algorithm'i isme güvenerek yapmak. "Türkçe sayı ayrıştırma" iki algoritmanın da adı, ama iki girdide farklı davranıyorlar. Eşdeğerlik testini kenar değerlerle (boş, işaretli, ayıracı yanlış yerde, başka dilin biçiminde) yaz.

Split Phase'de ara yapıyı atlamak. İki method'a bölünmüş ama birinci method ikincisini çağırıyorsa ya da ikisi ortak bir field'ı değiştiriyorsa, kod yine tek aşamadır. Aşamalar arasındaki tek bağ ara veri yapısı olmalı.

Preserve Whole Object ile katmanları birbirine bağlamak. Alt katmandaki bir hesaplama fonksiyonuna üst katmanın entity'sini vermek, o fonksiyonu tekrar kullanılamaz hâle getirir.

Küçük Bir Deneme

★☆☆ Kodunda hep birlikte dolaşan iki ya da üç parametre bul (tarih aralığı, sayfa numarası ve boyutu, para tutarı ve birimi). Introduce Parameter Object uygula ve ardından bu parametrelerle ilgili bir kontrolü yeni tipin içine taşı.

★★☆ Bir import ya da export kodunu Split Phase ile ikiye böl. Ara veri yapısını bulmak ne kadar zor oldu? Birinci aşamayı iş kurallarından bağımsız test edebiliyor musun?

★★★ Elle yazılmış bir ayrıştırma, biçimlendirme ya da hesaplama kodunu .NET'in kendi API'siyle değiştirmeyi düşündüğün bir yer seç. Değiştirmeden önce 20 kenar değerli bir eşdeğerlik testi yaz. Kaç girdi farklı çıktı ve her fark için eski mi yeni mi doğru?

Sorumluluklar artık doğru class'larda toplanıyor, ama bazı method'lar ve field'lar hâlâ başka bir class'ın verisiyle çalışıyor ve stok raporundaki tek bir döngü üç farklı iş yapıyor. Bir sonraki yazıda bir şeyi bir yerden başka bir yere taşıyan refactoring'lere geçiyoruz: Move Function, Move Field, Move Statements into Function ve Move Statements to Callers, Slide Statements, Split Loop, Replace Loop with Pipeline ve Remove Dead Code.