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

Küçük ve Güvenli Adımlar: IDE Refactoring'leri, Parallel Change ve Toplu Otomatik Düzeltmeler

Mert Özen 14 Kas 2026 13 dk 2 görüntülenme
Küçük ve Güvenli Adımlar: IDE Refactoring'leri, Parallel Change ve Toplu Otomatik Düzeltmeler

Büyük bir method'u açmanın güvenli yolu, her biri derlenip test edilen ve ayrı commit'e giren küçük adımlardır; imza değişikliği ise parallel change ile genişlet, taşı, daralt diye yürütülür. Yazıda PricingEngine.Calculate 14 commit'te hiç kırmızı commit olmadan açılıyor, dotnet format ile toplu düzeltme ayrı commit'e alınıyor, decimal scale farkını yakalayan snapshot ve rename'in JSON contract'ını ve convention tabanlı DI kaydını sessizce bozduğu durumlar gerçek çıktılarla gösteriliyor.

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

Önce Tahmin Et: Bu Method Kaç Commit'te Açılır?

Bookstore'un PricingEngine.Calculate method'u sepet toplamını, müşteri tipine ve kampanyaya göre indirimi, kuponu, yüzde 30'luk indirim tavanını ve küçük siparişlerin kargo ücretini tek gövdede hesaplıyor. Üç ayrı yerden çağrılıyor: ödeme, sepet önizlemesi ve teklif e-postası.

// customerType: 0 = regular, 1 = member, 2 = vip
public decimal Calculate(List<CartLine> lines, int customerType, string? campaign, string? coupon, bool isPreview)
{
    decimal total = 0;
    int count = 0;
    for (int i = 0; i < lines.Count; i++)
    {
        total = total + lines[i].UnitPrice * lines[i].Quantity;
        count = count + lines[i].Quantity;
    }

    decimal d = 0;
    if (customerType == 2)
    {
        if (campaign == "SUMMER")
            d = total * 0.20m;
        else
            d = total * 0.10m;
    }
    else if (customerType == 1)
    {
        if (campaign == "SUMMER")
            d = total * 0.15m;
        else if (count >= 5)
            d = total * 0.05m;
    }
    else
    {
        if (campaign == "SUMMER" && total >= 500)
            d = total * 0.10m;
    }

    if (coupon != null && coupon != "")
    {
        if (coupon.StartsWith("FIX"))
        {
            if (!(customerType == 2 && campaign == "SUMMER"))
                d = d + decimal.Parse(coupon.Substring(3));
        }
        else if (coupon == "WELCOME10")
        {
            if (customerType == 0)
                d = d + total * 0.10m;
        }
    }

    if (d > total * 0.30m)
        d = total * 0.30m;

    decimal result = total - d;
    if (!isPreview && result < 150)
        result = result + 29.90m;

    return Math.Round(result, 2);
}

Hedef, bu method'u okunur parçalara ayırmak ve beş parametreli imzayı bir parameter object'e taşımak. Sence bu iş kaç commit sürer? Ve bu süre boyunca testlerin toplam kaç dakika kırmızı kalması kabul edilebilir?

Ritim: Küçük Adım, Derle, Test Et, Commit

İkinci yazıdaki gibi işe güvenlik ağıyla başlıyoruz. Calculate'in girdi uzayı küçük ve sayılabilir olduğu için Verify'ın combination desteğiyle bütün kombinasyonları tek snapshot'ta onaylamak yeterli: üç sepet, üç müşteri tipi, iki kampanya durumu, dört kupon değeri (null ve boş string dahil) ve önizleme bayrağının iki hâli. Toplam 144 satır:

[Fact]
public Task Calculate_for_all_combinations() =>
    Combination()
        .Verify(
            (string cart, int customerType, string? campaign, string? coupon, bool isPreview) =>
                new PricingEngine().Calculate(Carts[cart], customerType, campaign, coupon, isPreview),
            Carts.Keys,
            [0, 1, 2],
            [null, "SUMMER"],
            [null, "", "WELCOME10", "FIX20"],
            [false, true]);
  one book  , 0, null  , null     , False: 149.90,
  one book  , 0, null  , null     , True : 120.0,
  one book  , 0, null  , WELCOME10, False: 137.90,
  one book  , 0, null  , WELCOME10, True : 108.00,

Bu testin bir saniyenin altında çalışması önemli; çünkü her küçük adımdan sonra çalıştıracağız. Ritim şu: tek bir dönüşüm yap, derle, testleri çalıştır, yeşilse commit et. Kırmızıysa düzeltmeye çalışma, adımı geri al ve daha küçük bir adım dene. Bu disiplinin değeri, bir hata çıktığında suçlunun her zaman son birkaç satır olması.

Adımların çoğunu IDE'nin otomatik refactoring'leri yapabilir: Rename, Extract Method, Introduce Parameter Object, Move. Bunlar Roslyn'in semantik modeliyle çalışır; bir değişkenin adını değiştirdiğinde aynı adı taşıyan başka bir değişkene dokunmaz, çıkardığın method'un parametrelerini kullanılan değişkenlerden hesaplar. Visual Studio ve Visual Studio Code'da Ctrl+., Rider'da Alt+Enter bu menüleri açar; ayrıntıları IDE'ler serisinin "Refactoring Araçları ve Code Style: Quick Actions, Alt+Enter, .editorconfig ve Code Cleanup" yazısında. Otomatik refactoring elle yazmaktan daha güvenlidir ama sihir değildir; yazının sonunda nerede yanıldığını göreceğiz.

Önce Mekanik Temizlik, Ayrı Commit'te

Elle düşünmeyi gerektirmeyen değişiklikleri araca bırakıp ayrı bir commit'e koymak, sonraki adımların diff'lerini temiz tutar. Calculate'teki x = x + y atamaları ve Substring çağrısı için iki IDE kuralı var: IDE0054 (compound assignment) ve IDE0057 (range operator). Önce aracın ne yapacağına bakıyoruz:

$ dotnet format style --diagnostics IDE0054 IDE0057 --severity info --verify-no-changes
src/Bookstore.Pricing/PricingEngine.cs(12,19): info IDE0054: Use compound assignment [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
src/Bookstore.Pricing/PricingEngine.cs(13,19): info IDE0054: Use compound assignment [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
src/Bookstore.Pricing/PricingEngine.cs(42,23): info IDE0054: Use compound assignment [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
src/Bookstore.Pricing/PricingEngine.cs(47,23): info IDE0054: Use compound assignment [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
src/Bookstore.Pricing/PricingEngine.cs(56,20): info IDE0054: Use compound assignment [src/Bookstore.Pricing/Bookstore.Pricing.csproj]
src/Bookstore.Pricing/PricingEngine.cs(42,60): info IDE0057: Substring can be simplified [src/Bookstore.Pricing/Bookstore.Pricing.csproj]

--verify-no-changes olmadan aynı komut düzeltmeleri uygular. Sonuç beş satırlık bir diff; satır satır okuyup testleri çalıştırdıktan sonra kendi commit'ine giriyor:

-                    d = d + decimal.Parse(coupon.Substring(3));
+                    d += decimal.Parse(coupon[3..]);

IDE'de aynı işin karşılığı ampul menüsündeki "Fix all occurrences in: Document / Project / Solution" seçeneği. Komut satırında IDE ile başlayan code-style kuralları dotnet format style, CA (Microsoft'un kod analizi kuralları) ile başlayan ya da üçüncü parti analyzer kuralları dotnet format analyzers ile düzeltilir; ikisi de --diagnostics ile tek tek kural seçmeye izin verir. Toplu düzeltmeyi kural kural yapmak, yüzlerce dosyayı etkileyen bir diff'i bile review edilebilir kılar: reviewer'ın tek sorusu "bu kural doğru uygulanmış mı" olur.

Java dünyasında bu tür toplu dönüşümlerin bilinen aracı OpenRewrite; C# desteğini Moderne geliştiriyor. 14 Kasım 2026 itibarıyla C# parser'ı ve temel tarifler Moderne Source Available License ile geliyor, yani kendi kodunda ücretsiz çalıştırabilirsin. Katalogdaki birçok C# tarifi ise (kod kalitesi ve dependency analizi tarifleri gibi) "Proprietary" etiketli ve ticari lisans istiyor. C# tarifleri Maven ya da Gradle plugin'iyle değil, Moderne CLI ile (mod run) çalıştırılıyor. Kullanmadan önce seçtiğin tarifin lisansına tek tek bakmak gerekiyor. Kendi kod tabanına özel toplu dönüşümler yazmak (custom Roslyn analyzer ve code fix) ise Statik Analiz serisinin konusu.

Calculate'i Açmak: Rename, Split, Extract, Move

İlk adım isimler. d, total, count ve result; discount, subtotal, bookCount ve net oluyor. Rename refactoring'i tek değişkeni bütün kullanımlarıyla birlikte değiştirdiği için bu adım neredeyse risksiz, ama sonraki adımların okunması için en değerlisi.

İkinci adım döngüyü bölmek. Tek döngü iki işi birden yapıyor: tutarı ve kitap adedini topluyor. Bu iki işi ayırınca her biri tek satırlık bir ifadeye dönüşüyor:

decimal subtotal = lines.Sum(l => l.UnitPrice * l.Quantity);
int bookCount = lines.Sum(l => l.Quantity);

Listeyi iki kez dolaşmak performans açısından ölçülebilir bir fark yaratmıyor; bir sepet en fazla birkaç düzine satır. Performans bir gün sorun olursa ölçülür ve gerekirse tek döngüye dönülür.

Üçüncü, dördüncü ve beşinci adımlar Extract Method: müşteri tipi ve kampanyaya bağlı indirim TierDiscount'a, kupon CouponDiscount'a, kargo ücreti ShippingFee'ye çıkıyor. Kargo adımında küçük bir ders var. Elle yazdığım ilk sürüm şuydu:

private static decimal ShippingFee(decimal net) => net < 150 ? 29.90m : 0.00m;

Matematiksel olarak doğru, ama snapshot 144 kombinasyonun 13'ünde kırmızıya döndü:

<   five books, 0, null  , null     , False: 375.0,
---
>   five books, 0, null  , null     , False: 375.00,

.NET'te decimal ondalık basamak sayısını (scale) değerin bir parçası olarak taşır. 375m + 0.00m sonucu 375,00'dir ve ToString(), JSON serializer ve Verify onu "375.00" diye yazar. Eski kod kargo ücreti olmayan siparişlere hiçbir şey eklemiyordu, yani scale korunuyordu. 0m ile yazınca test tekrar yeşile döndü. Bu tür bir farkı reviewer'ın gözü yakalamaz; API yanıtını tüketen bir istemci ise yakalayabilir. İyi bir güvenlik ağının değeri tam olarak bu.

Son adım Move: kargo ücreti fiyatlandırmanın değil, kargo politikasının bilgisi. ShippingFee'yi ShippingPolicy.FeeFor adıyla kendi static class'ına taşıyoruz. Sonuçta Calculate'in gövdesi niyetini okuyan bir metne dönüştü:

public decimal Calculate(PriceRequest request, bool isPreview)
{
    (List<CartLine> lines, int customerType, string? campaign, string? coupon) = request;

    decimal subtotal = lines.Sum(l => l.UnitPrice * l.Quantity);
    int bookCount = lines.Sum(l => l.Quantity);

    decimal discount = TierDiscount(customerType, campaign, subtotal, bookCount);

    discount += CouponDiscount(coupon, customerType, campaign, subtotal);

    if (discount > subtotal * 0.30m)
        discount = subtotal * 0.30m;

    decimal net = subtotal - discount;
    if (!isPreview)
        net += ShippingPolicy.FeeFor(net);

    return Math.Round(net, 2);
}

TierDiscount ve CouponDiscount'ın içindeki iç içe if'ler hâlâ yerinde ve customerType hâlâ bir int. Bunlar bilerek bırakıldı: koşul yapısını değiştirmek mekanik bir adım değil, bir tasarım kararı ve dördüncü yazının konusu.

Cevap: On Dört Commit, Hiç Kırmızı Commit Yok

Bütün dönüşüm şu commit'lerde yapıldı. Her birinden önce testler çalıştı ve hiçbiri kırmızı commit edilmedi; kırmızı kalınan tek an, kargo adımındaki scale farkını gördüğüm birkaç dakikaydı.

$ git log --oneline
c26eaad refactor: move ShippingFee to ShippingPolicy
625958b refactor: remove obsolete Calculate overload (contract)
de6305d test: characterization test uses PriceRequest
a900882 refactor: QuoteEmail uses PriceRequest (migrate 3/3)
dcd8733 refactor: CartPreview uses PriceRequest (migrate 2/3)
58f1a98 refactor: CheckoutService uses PriceRequest (migrate 1/3)
0025068 refactor: introduce PriceRequest overload (expand)
80f3491 refactor: extract ShippingFee
6f69f04 refactor: extract CouponDiscount
ca8167f refactor: extract TierDiscount
5758d9e refactor: split the totals loop into two Sum calls
cd9cb85 refactor: rename locals in Calculate
e2a6762 style: IDE0054 and IDE0057 via dotnet format
8bf1f27 test: characterize PricingEngine.Calculate (144 combinations)

On dört commit çok görünebilir, ama her biri birkaç satırlık ve tek bir şey yapıyor. Bir hata üretimde ortaya çıksa git bisect onu tek adımlık bir diff'e kadar daraltır. Hepsini tek bir "refactor PricingEngine" commit'inde toplasaydık aynı hatayı dosyanın neredeyse tamamını kapsayan bir diff'te arıyor olurduk. PR'ın (pull request; değişikliğin incelenip ana branch'e alınması için açılan istek) kendisini kaç parçaya böleceğin ise ekibinin review kapasitesine bağlı; bir sonraki bölümdeki imza değişikliği bunun için iyi bir sınır çiziyor.

İmza Değişikliği: Parallel Change

Calculate(lines, customerType, campaign, coupon, isPreview) imzasını Calculate(PriceRequest request, bool isPreview)'e çevirmek, bütün çağıranları aynı anda değiştirmeyi gerektirir. Üç çağıran aynı repo'daysa IDE'nin Change Signature refactoring'i bunu tek hamlede yapabilir. Ama çağıranlar başka ekiplerin kodunda, başka bir repo'da ya da bir NuGet paketinin kullanıcılarındaysa tek hamle mümkün değildir. Danilo Sato'nun 2014'te martinfowler.com'da anlattığı parallel change (expand and contract) bu durum için: değişikliği üç ayrı aşamaya bölmek.

Genişletme aşamasında yeni imza eklenir ve eski imza ona yönlenir. Eskiyi [Obsolete] ile işaretlemek, derleyicinin kalan çağıranları bizim yerimize listelemesini sağlar:

[Obsolete("Use Calculate(PriceRequest, bool) instead.")]
public decimal Calculate(List<CartLine> lines, int customerType, string? campaign, string? coupon, bool isPreview) =>
    Calculate(new PriceRequest(lines, customerType, campaign, coupon), isPreview);

public decimal Calculate(PriceRequest request, bool isPreview)
{
    // ... eski gövde
$ dotnet build --no-incremental
src/Bookstore.Ordering/Callers.cs(8,9): warning CS0618: 'PricingEngine.Calculate(List<CartLine>, int, string?, string?, bool)' is obsolete: 'Use Calculate(PriceRequest, bool) instead.' [src/Bookstore.Ordering/Bookstore.Ordering.csproj]
src/Bookstore.Ordering/Callers.cs(14,9): warning CS0618: 'PricingEngine.Calculate(List<CartLine>, int, string?, string?, bool)' is obsolete: 'Use Calculate(PriceRequest, bool) instead.' [src/Bookstore.Ordering/Bookstore.Ordering.csproj]
src/Bookstore.Ordering/Callers.cs(20,24): warning CS0618: 'PricingEngine.Calculate(List<CartLine>, int, string?, string?, bool)' is obsolete: 'Use Calculate(PriceRequest, bool) instead.' [src/Bookstore.Ordering/Bookstore.Ordering.csproj]
tests/Bookstore.Pricing.Tests/PricingEngineCharacterizationTests.cs(19,21): warning CS0618: 'PricingEngine.Calculate(List<CartLine>, int, string?, string?, bool)' is obsolete: 'Use Calculate(PriceRequest, bool) instead.' [tests/Bookstore.Pricing.Tests/Bookstore.Pricing.Tests.csproj]

Dört uyarı, dört çağıran. Taşıma aşamasında her çağıran kendi commit'inde yeni imzaya geçiyor ve uyarı sayısı 4, 3, 2, 1 diye azalıyor. Characterization testini bilerek en sona bıraktım: o eski imzayı çağırdığı sürece, eski imzanın yeni koda doğru yönlendirdiğini de doğruluyor. Son çağıran taşındığında uyarı sayısı sıfır; daraltma aşamasında eski overload siliniyor.

Bu üç aşama tek bir PR'a sığabilir, sığmayabilir. Kural basit: her aşamanın sonunda sistem derlenir, testler geçer ve deploy edilebilir durumdadır. Çağıranlar başka ekiplerdeyse genişletme bir PR, her ekibin taşıması kendi PR'ı, daraltma da herkes taşındıktan sonra ayrı bir PR olur. Kütüphane yazıyorsan eski imzayı en az bir sürüm boyunca [Obsolete] olarak taşıman, kullanıcılarına taşınma zamanı tanır. TreatWarningsAsErrors açık bir projede CS0618 build'i kırar; genişletme aşamasında bu uyarıyı geçici olarak WarningsNotAsErrors'a eklemek gerekebilir.

Otomatik Refactoring'in Göremediği Yerler

IDE'nin rename'i C# sembollerini izler. Bir ismin C# dışında, bir string'in ya da bir convention'ın içinde yaşadığı yerleri göremez. Aşağıdaki küçük program iki yaygın durumu gösteriyor:

// 1) JSON contract: property adı API alanının adıdır.
Console.WriteLine(JsonSerializer.Serialize(new PriceQuote(375m, 0m)));
Console.WriteLine(JsonSerializer.Serialize(new PriceQuote(375m + 0.00m, 0m)));

// 2) Convention ile kayıt: adı "Rule" ile biten her class bir indirim kuralıdır.
ServiceCollection services = new();
foreach (Type type in Assembly.GetExecutingAssembly().GetTypes()
             .Where(t => t.IsClass && !t.IsAbstract && t.Name.EndsWith("Rule")))
{
    services.AddSingleton(typeof(IDiscountRule), type);
}
using ServiceProvider provider = services.BuildServiceProvider();
Console.WriteLine($"Registered rules: {string.Join(", ", provider.GetServices<IDiscountRule>().Select(r => r.GetType().Name))}");

public sealed record PriceQuote(decimal Total, decimal Discount);

public interface IDiscountRule;
public sealed class MemberRule : IDiscountRule;
public sealed class VipRule : IDiscountRule;
public sealed class SummerCampaignRule : IDiscountRule;
{"Total":375,"Discount":0}
{"Total":375.00,"Discount":0}
Registered rules: MemberRule, VipRule, SummerCampaignRule

Şimdi iki rename yapıyoruz: PriceQuote.Total, GrandTotal oluyor; VipRule da daha açıklayıcı olsun diye VipDiscount. Build sıfır uyarı, sıfır hata. Çıktı ise:

{"GrandTotal":375,"Discount":0}
{"GrandTotal":375.00,"Discount":0}
Registered rules: MemberRule, SummerCampaignRule

API'nin JSON contract'ı değişti ve bu alanı okuyan her istemci sessizce null görmeye başlayacak. VIP (özel statülü müşteri) indirimi de DI (dependency injection; bağımlılıkları dışarıdan veren mekanizma) container'ından düştü; hiçbir derleyici hatası ya da exception yok, sadece VIP müşteriler artık indirim almıyor. İkinci satır da kargo adımındaki scale konusunun API'deki karşılığı: aynı tutar, farklı JSON.

Aynı aile genişler. Reflection'la string üzerinden bulunan method'lar (GetMethod("Calculate")), Entity Framework Core'un (EF Core; .NET'in veritabanı erişim kütüphanesi) property adından türettiği kolon adı (rename bir sonraki migration'da kolon rename'ine dönüşür), Razor ve Angular template'lerindeki property adları, appsettings.json'daki section adlarına bağlanan options class'ları, log mesajlarındaki structured logging alan adları. Bu durumların kod tarafında nasıl korunacağını ([JsonPropertyName], HasColumnName, tolerant reader) yedinci yazıda, sözleşme değişirken refactoring başlığı altında ayrıntılı göreceğiz. Şimdilik pratik kural: bir rename'den sonra çözüm genelinde eski ismi düz metin olarak ara; IDE'lerin "rename in strings and comments" seçeneğini bilinçli kullan; ve bu tür isimleri sabitleyen testlerin (JSON snapshot, DI registration testi) olsun.

Sık Düşülen Tuzaklar

Büyük adımla başlayıp saatlerce kırmızıda kalmak. "Bütün indirim mantığını strategy'lere taşıyayım, sonra testleri çalıştırırım" diye başlanan iş, iki saat sonra 40 derleme hatası ve hangi değişikliğin neyi bozduğunu bilmeyen bir geliştiriciyle biter. Test kırmızıya döndüğünde iki dakikadan uzun süre düzeltemiyorsan geri al (git restore ya da git stash) ve adımı küçült.

Otomatik araç çıktısını review etmeden merge etmek. dotnet format ya da "Fix all in solution" yüzlerce dosyayı değiştirebilir. Araç kuralı doğru uygular, ama kural her bağlamda doğru olmayabilir: bir code fix'in public API'yi değiştirdiği ya da generated koda dokunduğu durumlar vardır. Toplu düzeltme ayrı commit'te gelir, kural kural uygulanır ve en az örnekleme yoluyla okunur.

Mekanik değişiklikle anlamlı değişikliği aynı commit'e koymak. Format düzeltmesi ve bir extract method aynı diff'te olunca reviewer önemli satırı yüzlerce girinti değişikliği arasında arar. Birinci yazıdaki iki şapka kuralının araç versiyonu: mekanik olan ayrı, düşünce gerektiren ayrı.

Parallel change'de daraltmayı unutmak. Genişletme yapılır, çağıranların yarısı taşınır, sonra öncelikler değişir ve kod tabanında aynı işi yapan iki imza yıllarca yaşar. Daraltma adımını bir issue olarak açmak ve [Obsolete] uyarılarını takip etmek bunu önler.

Snapshot'ı "nasılsa bir şey değişmedi" diye güncellemek. Kargo adımındaki 13 satırlık fark ilk bakışta önemsiz göründü. Önemsiz olup olmadığına snapshot'ı güncelleyerek değil, farkın nereden geldiğini anlayarak karar verilir.

Küçük Bir Deneme

★☆☆ Kendi kod tabanında dotnet format style --diagnostics IDE0054 --severity info --verify-no-changes komutunu çalıştır. Kaç dosyada kaç ihlal var? Sonucu tek commit'te uygulamak için review'a ne kadar süre gerektiğini tahmin et.

★★☆ Bu yazıdaki Calculate'i, combination snapshot'ı kurup, kendin adım adım aç. Her adımdan sonra testi çalıştır ve git log'unu bu yazıdakiyle karşılaştır. Daha az ya da daha çok commit'e nerede ihtiyaç duydun?

★★★ Ekibinin bir public method'unda parallel change uygula: yeni imzayı ekle, eskisini [Obsolete] yap, dotnet build --no-incremental ile CS0618 listesini al. Çağıranları kaç PR'a böleceğini ve daraltmanın ne zaman yapılacağını bir issue'ya yaz.

Calculate artık okunuyor, ama TierDiscount ve CouponDiscount'ın içindeki koşul yığını yerinde duruyor ve isPreview bayrağı tek method'a iki iş yaptırıyor. Bir sonraki yazıda bu koşulları üç farklı hedefe, polymorphism'e, switch expression'a ve kural tablosuna dönüştürüp hangisinin hangi değişim türüne uygun olduğunu tartacağız.