Teknik Borcu Önceliklendirmek ve Refactoring'i Savunmak: Hotspot Analizi, Ölçüm ve Product Owner ile Konuşmak
Refactoring yatırımı en çirkin koda değil, en sık değişen karmaşık koda, yani hotspot'lara yapılır ve Product Owner'a değişim maliyeti, lead time ve hata oranı üzerinden anlatılır. Yazıda git log ve Roslyn ile Cognitive Complexity hesaplayan küçük bir C# script'i Bookstore repo'sunda çalıştırılıyor; PricingEngine'in birinci hotspot çıkması, InvoiceRenderer ile yüzde 59'luk change coupling ve sekiz fatura hatası gerçek çıktılarla gösteriliyor.
Sürüm notu: Bu yazıdaki script .NET 10 (SDK 10.0.402) ile file-based app olarak, Microsoft.CodeAnalysis.CSharp 5.12.0 ile derlenip çalıştırılmıştır; git çıktıları Git 2.54 ile alınmıştır.
Önce Tahmin Et: İki Haftan Var, Hangisine Harcarsın?
Product Owner sonunda "bu sprint ve bir sonrakinde, ekip kapasitesinin bir kısmını teknik borca ayırabiliriz" dedi. Adaylar bu serinin legacy parçaları ve ekipte herkesin bir favorisi var. InvoiceRenderer'da faturanın her satırını tek bir Render method'u üretiyor; açan herkes yüzünü buruşturuyor. LegacyShippingClient 2014'ten kalma, derlenirken SYSLIB0014 uyarısı veriyor, obsolete bir API kullanıyor. PricingEngine.Calculate iç içe koşullarla dolu. OrderImportJob static helper'lara ve DateTime.Now'a bağlı. Katalog API'si entity'leri doğrudan JSON olarak dönüyor.
Ekipte iki ses var. Biri "en kötü kodu düzeltelim: InvoiceRenderer" diyor, diğeri "obsolete API'den kurtulalım: LegacyShippingClient" diyor. Sen hangisini seçerdin? Daha önemlisi, seçimini Product Owner'a hangi gerekçeyle anlatırdın?
Hotspot Fikri: Sık Değişen ve Karmaşık
Adam Tornhill'in Your Code as a Crime Scene kitabının (ikinci baskı, 2024) temel fikri şu: kodun kalitesi, ancak o koda dokunduğunda bir maliyet üretir. Kimsenin açmadığı karmaşık bir dosya, ne kadar çirkin olursa olsun, bugün kimseyi yavaşlatmıyor. Her hafta değişen karmaşık bir dosya ise her değişiklikte ek maliyet çıkarıyor: anlamak uzun sürüyor, hata riski yüksek, review zor. Hotspot, bu iki boyutun kesiştiği yer: sık değişen ve karmaşık kod.
İki boyutun da verisi elimizde. Değişim sıklığı Git geçmişinde: bir dosyaya son bir yılda kaç commit dokundu? Karmaşıklık kodun bugünkü hâlinde: Tornhill satır sayısı ve girinti derinliği gibi basit ölçüler kullanıyor; biz bu yazıda Cognitive Complexity kullanacağız. G. Ann Campbell'ın SonarSource için yazdığı white paper'da tanımlanan bu ölçü, kodun okunmasını zorlaştıran şeyleri sayıyor: akışı bölen her if, döngü, catch ve switch için +1, iç içe her seviye için ek puan, mantıksal operatör dizileri için +1. Cyclomatic complexity'den farkı, bir switch'i vakaları ne kadar çok olursa olsun bir kez sayması ve iç içe yapıyı daha pahalı tutması.
Bookstore Repo'sunu Ölçmek: git log ve Küçük Bir C# Script'i
Bu yazı için seri boyunca kullandığımız legacy dosyalardan bir Bookstore repo'su kurdum ve ona bir yıllık bir değişiklik geçmişi verdim: kampanyalar, partner dosyaları, katalog alanları. Aşağıdaki sayılar o repo üzerinde script'in gerçek çıktısı; senin repo'nda aynı script'i çalıştırdığında kendi geçmişini görürsün. Script .NET 10 ile gelen file-based app özelliğini kullanıyor: tek bir .cs dosyası, proje dosyası yok, paket #:package direktifiyle geliyor ve dotnet run hotspots.cs -- ../bookstore ile çalışıyor. İlk kısım değişim sıklığı:
#:package Microsoft.CodeAnalysis.CSharp@5.12.0
#:property PublishAot=false
// 1. Değişim sıklığı: dönem içinde her C# dosyasına kaç commit dokundu.
List<string[]> commits = ParseCommits(Git(repo, "log", $"--since={since}", "--name-only", "--format=>%h", "--", "*.cs"));
Dictionary<string, int> revisions = commits.SelectMany(files => files)
.GroupBy(file => file)
.ToDictionary(g => g.Key, g => g.Count());
// Her commit ">hash" satırıyla başlar, ardından dokunduğu dosyalar gelir.
static List<string[]> ParseCommits(string log) =>
log.Split('>', StringSplitOptions.RemoveEmptyEntries)
.Select(c => c.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Skip(1).ToArray())
.Where(files => files.Length > 0)
.ToList();
İkinci kısım karmaşıklık. Roslyn ile her dosyanın syntax tree'sini çıkarıp her method'u bir CSharpSyntaxWalker'dan geçiriyoruz. Kuralların tamamı değil, çoğunu uygulayan sade bir sürüm; recursion ve bazı dil özelliklerine özel kurallar dışarıda:
// Basitleştirilmiş Cognitive Complexity (G. Ann Campbell, SonarSource): akışı bölen her yapı +1,
// başka bir yapının içindeyse ayrıca iç içe olma seviyesi kadar; else/else if iç içe puanı almadan +1.
sealed class CognitiveComplexity : CSharpSyntaxWalker
{
private int _nesting;
private int _score;
private void Structure(SyntaxNode? condition, SyntaxNode body)
{
_score += 1 + _nesting;
Visit(condition);
Nested(body);
}
public override void VisitForEachStatement(ForEachStatementSyntax node) => Structure(node.Expression, node.Statement);
public override void VisitCatchClause(CatchClauseSyntax node) => Structure(node.Filter, node.Block);
// Her yeni && ya da || dizisi +1: a && b && c için 1, a && b || c için 2.
public override void VisitBinaryExpression(BinaryExpressionSyntax node)
{
if (node.Kind() is SyntaxKind.LogicalAndExpression or SyntaxKind.LogicalOrExpression
&& !(node.Parent is BinaryExpressionSyntax parent && parent.Kind() == node.Kind()))
_score += 1;
base.VisitBinaryExpression(node);
}
// ... if/else, switch, ?:, goto, lambda ve local function'lar
}
Kendi yazdığın bir metriğe güvenmeden önce onu bilinen sonuçlarla sınamak gerekir. White paper'daki iki örneği (iç içe döngülerle asal sayıları toplayan bir method ve dört vakalı bir switch) C#'a çevirip script'e verdim; makaledeki değerler 7 ve 1:
$ dotnet run hotspots.cs -- samples.cs
SumOfPrimes: 7
GetWords: 1
Cevap: Ne InvoiceRenderer Ne LegacyShippingClient
Script'i son on iki ayın geçmişiyle repo üzerinde çalıştırdım:
$ dotnet run hotspots.cs -- ../bookstore
88 commits since 12.months
File Revs CogC Score Most complex member
Pricing/PricingEngine.cs 41 28 1148 Calculate (28)
Invoicing/InvoiceRenderer.cs 23 41 943 Render (41)
Catalog/Program.cs 15 6 90 <top-level> (6)
Import/OrderImportJob.cs 13 6 78 ExecuteAsync (3)
Catalog/CsvExport.cs 3 1 3 Write (1)
En karmaşık dosya gerçekten InvoiceRenderer: tek method'u 41 puan. Ama bir yılda 23 kez değişmiş; PricingEngine ise 41 kez. Değişim sıklığı ile karmaşıklığı çarpınca listenin başına PricingEngine çıkıyor. LegacyShippingClient listede yok bile:
$ dotnet run hotspots.cs -- ../bookstore/src/Bookstore.Shipping/Legacy/LegacyShippingClient.cs
Endpoint.get: 0
Endpoint.set: 0
GetRate: 0
CreateShipment: 0
Post: 0
$ git log --since=12.months --oneline -- src/Bookstore.Shipping/Legacy/LegacyShippingClient.cs
ee192e0 fix: carrier timeout isbn13
9890041 fix: carrier timeout partner-a
Obsolete bir API kullanıyor ve kimse onu sevmiyor, ama karmaşık değil ve bir yılda iki kez değişmiş. Bugün onu yeniden yazmak, ekibe hiçbir değişikliği ucuzlatmayan bir harcama olurdu. Altıncı yazıdaki geçiş ancak kargo firması eski servisi kapatınca, yani o kod gerçekten değişmek zorunda kalınca anlamlı hâle geldi.
Skor kesin bir sıralama değil, bir dikkat listesi. 1148 ile 943 arasındaki fark "biri diğerinden yüzde 20 daha önemli" anlamına gelmiyor; anlamı, bu iki dosyanın listenin geri kalanından çok daha yukarıda durduğu. Kararı bir sonraki bölümdeki veri netleştiriyor.
Change Coupling: Birlikte Değişen Dosyalar
Script'in üçüncü kısmı aynı commit'te değişen dosya çiftlerini sayıyor. Derece, ortak commit sayısının iki dosyanın ortalama commit sayısına oranı:
Coupled files Shared Degree
Shipping/Checkout/CheckoutService.cs <-> Web/Program.cs 5 100%
Catalog/Book.cs <-> Catalog/Program.cs 9 67%
Invoicing/InvoiceRenderer.cs <-> Pricing/PricingEngine.cs 19 59%
Pricing/CartLine.cs <-> Pricing/PricingEngine.cs 6 26%
Her coupling kötü değil. CheckoutService ile onu endpoint'e bağlayan Program.cs'in birlikte değişmesi beklenen bir şey; ikisi aynı özelliğin iki ucu. Book.cs ile katalog Program.cs'i, endpoint'ler entity'yi doğrudan döndüğü için birlikte değişiyor; yedinci yazıda bunun neden riskli olduğunu gördük. Asıl bulgu üçüncü satır: InvoiceRenderer'ın 23 değişikliğinin 19'u PricingEngine ile aynı commit'te. Farklı klasörlerde, farklı ekiplerin sahiplendiği iki class, birbirini import etmeden birlikte değişiyor. Commit mesajları nedenini söylüyor:
$ git log --since=12.months --oneline --grep="invoice total differs from cart"
05e48c9 fix: invoice total differs from cart for audiobooks
f7476c6 fix: invoice total differs from cart for black-friday
a20fc1f fix: invoice total differs from cart for summer
66f803e fix: invoice total differs from cart for back-to-school
6ddc00f fix: invoice total differs from cart for ebooks
d3eadb7 fix: invoice total differs from cart for audiobooks
6a8794e fix: invoice total differs from cart for kids
5c84a44 fix: invoice total differs from cart for partner-a
Bir yılda sekiz kez, faturadaki toplam sepetteki toplamla tutmamış. InvoiceRenderer kupon indirimini ve vergiyi kendi içinde yeniden hesaplıyor; her yeni kampanya önce PricingEngine'e, sonra (çoğu zaman eksik) InvoiceRenderer'a ekleniyor. Bu, Clean Code serisinin "Code Smell Kataloğu" yazısındaki shotgun surgery'nin veriyle görünen hâli. Yatırımın hedefi artık net: önce PricingEngine'deki kuralları dördüncü yazıdaki gibi okunur hâle getirmek, sonra InvoiceRenderer'ın fiyatı kendisi hesaplamak yerine PricingEngine'in sonucunu basmasını sağlamak. Render'ın geri kalanı, yani başlık, satır biçimi ve hediye paketi mantığı, beklemeye devam edebilir.
Ne Zaman Refactor Edilir?
Hotspot listesi neye yatırım yapılacağını söylüyor; ne zaman sorusunun üç cevabı var ve üçü birlikte çalışıyor. Birincisi sürekli küçük temizlik (boy scout rule): bir dosyaya bir iş için dokunduğunda onu bulduğundan biraz daha iyi bırakmak. Hotspot'lar zaten sık dokunulan dosyalar olduğu için bu alışkanlık en çok onlara yarar. İkincisi, ilk yazıda anlattığımız hazırlık için refactoring: yeni kampanya kuralı PricingEngine'e zor sığıyorsa, önce yapıyı değiştirip sonra kuralı eklemek. Üçüncüsü ayrılmış zaman: birkaç günden uzun sürecek, bir özelliğin içine sığmayan işler için, bu yazının başındaki gibi açıkça ayrılmış kapasite.
Ayrılmış zamanın çalışması için teknik borcun görünür olması gerekir. Ward Cunningham'ın 1992'de ortaya attığı borç metaforu burada işe yarıyor: borcun bir anaparası (düzeltme maliyeti) ve bir faizi (her değişiklikte ödenen ek maliyet) var. Hotspot analizi faizi en yüksek borcu gösteriyor. Teknik borcun Product Backlog'da nasıl görünür tutulacağı Scrum serisinin "Scrum'da Teknik Pratikler: Branching, CI, Feature Flag ve Teknik Borç" yazısında anlatıldı; burada onu tekrar etmiyoruz.
Product Owner'a Anlatmak: Değişim Maliyeti, Lead Time, Hata Oranı
"Kod çok kötü, refactor etmemiz lazım" cümlesi Product Owner'a hiçbir şey anlatmaz; o kodun kötü olup olmadığını değerlendiremez ve değerlendirmek zorunda da değil. Onun dilinde üç soru var: yeni bir özellik ne kadar sürüyor, ne kadar öngörülebilir ve ne sıklıkla hatayla dönüyor? Refactoring önerisi bu üç sorudan birine cevap vermeli.
Bookstore için konuşma şuna benziyor. Son bir yılda fiyatlama tarafına 41 değişiklik girdi ve sekiz kez müşteriye sepetteki fiyattan farklı bir fatura gitti; her biri bir destek kaydı, bir düzeltme ve bir deploy demek. Her kampanya iki ayrı yerde, iki kez yazılıyor. Önümüzdeki çeyrekte planlanan beş kampanyanın her biri aynı yoldan geçecek. Öneri: iki sprint boyunca kapasitenin bir kısmıyla faturanın fiyatı fiyatlama motorundan almasını sağlamak. Başarının ölçüsü: kampanya işlerinin başlangıçtan production'a kadar geçen süresi ve "fatura tutmuyor" hatalarının sayısı.
Bu ölçüler DORA (DevOps Research and Assessment; yazılım teslimat performansını inceleyen araştırma programı) metriklerinin iki tanesine karşılık geliyor: lead time for changes ve change failure rate. Genel bir dayanak isteyen için Adam Tornhill ve Markus Borg'un 2022 tarihli "Code Red" çalışması var. 39 kapalı kaynak kod tabanını inceleyen çalışmada düşük kaliteli kodda yaklaşık 15 kat daha fazla hata görüldüğü ve görevlerin daha uzun, daha öngörülemez sürdüğü raporlanıyor. Yazarların, kullanılan kalite metriğini üreten CodeScene şirketinden olduğunu da söylemek gerekir. Kendi verin her zaman daha ikna edicidir.
Önce ve Sonra Ölçmek
Refactoring'i savunmanın en sağlam yolu, bir sonraki konuşmaya veriyle gelmek. Bunun için ölçümü işe başlamadan önce almak gerekiyor. Bookstore için başlangıç noktası bu yazıdaki çıktılarda: PricingEngine.Calculate 28 puan, InvoiceRenderer ile change coupling yüzde 59, bir yılda sekiz "fatura tutmuyor" düzeltmesi. Issue tracker'dan da fiyatlama işlerinin ortalama ve en uzun süresini almak gerekiyor; bu veri repo'da değil.
Üç ay sonra aynı script'i aynı repo'da --since değerini refactoring sonrasına çekerek çalıştırıp şu sorulara bakıyoruz: InvoiceRenderer, PricingEngine ile hâlâ birlikte değişiyor mu? Calculate'in puanı düştü mü? Kampanya işlerinin süresi kısaldı mı, "fatura tutmuyor" kayıtları durdu mu? Cevap evetse bir sonraki teknik borç konuşması çok daha kısa sürer. Hayırsa, yanlış yere yatırım yaptığımızı erken öğrenmiş oluruz; bu da değerli bir bilgi.
Sık Düşülen Tuzaklar
En kötü koda yatırım yapmak. Bu yazının ana dersi: yatırım en çirkin koda değil, en çok dokunulan karmaşık koda yapılır. LegacyShippingClient'ı bugün yeniden yazmak, ekibin günlük işine hiçbir şey kazandırmazdı.
"Refactoring sprint'i" ile işi biriktirmek. Teknik borcu bir sprint'e toplamak, o sprint gelene kadar faizin ödenmeye devam etmesi demek; çoğu zaman da o sprint hiç gelmez. Sürekli temizlik ve hazırlık refactoring'i işin büyük kısmını yapar, ayrılmış zaman yalnız büyük ve planlı işler içindir.
Metriği hedef yapmak. "Cognitive Complexity 15'in altına inecek" diye bir hedef koyarsan ekip method'ları anlamsız parçalara bölerek sayıyı düşürür. Hotspot analizi bir karar aracı, performans göstergesi değil.
Geçmişi temizlemeden ölçmek. Bütün dosyalara dokunan toplu bir format commit'i ya da dosya taşıma, değişim sayılarını bozar. Bu script git log'u dosya adlarıyla kullanıyor; yeniden adlandırılan bir dosyanın eski geçmişi yeni adına eklenmez. Büyük bir taşıma sonrası analiz yapıyorsan dönemi taşımadan sonrasına çek ya da toplu commit'leri ayıkla.
Sayıyı bağlamsız sunmak. "PricingEngine'in skoru 1148" Product Owner için hiçbir şey ifade etmez; "son bir yılda sekiz kez müşteriye yanlış fatura gitti ve her yeni kampanya iki yerde yazılıyor" ise bir iş problemi.
Küçük Bir Deneme
★☆☆ Kendi repo'nda son bir yılda en çok değişen on dosyayı bul. Ekipte "en kötü kod" diye anılan dosya bu listede mi? Değilse, onu bugün yeniden yazmak neyi ucuzlatırdı?
★★☆ Bu yazıdaki hotspots.cs'i kendi repo'nda çalıştır. İlk beş hotspot'tan biri için change coupling listesine bak: o dosya en sık hangi dosyayla birlikte değişiyor ve bu beklenen bir ilişki mi?
★★★ Bir hotspot seç ve Product Owner'a götüreceğin bir sayfalık öneriyi yaz: hangi sorun, hangi veriyle görülüyor, öneri ne, ne kadar sürer, başarı nasıl ölçülecek? Teknik terim kullanmadan yazmayı dene.
Seri burada bitiyor. Dokuz yazıda refactoring'i bir teknik listesi olarak değil, bir yolculuk olarak ele aldık: önce davranışı ölçen bir güvenlik ağı, sonra küçük ve geri alınabilir adımlar, legacy kodda dependency kırmak, büyük değişiklikleri Mikado ve branch by abstraction ile trunk'ta yürütmek, contract'ları expand-contract ile taşımak, Angular kodunu schematic'lerle ve elle modernleştirmek ve son olarak bütün bunlara nereden başlanacağını veriyle seçmek. Ortak nokta hep aynıydı: her adımda sistem çalışıyor ve neyin değiştiğini bir test ya da bir ölçüm söylüyor.