Debugging Bir Yöntemdir: Gözlem, Hipotez, Deney ve 9 Altın Kural
Debugging tahminle değil yöntemle yapılır: gözlem, test edilebilir hipotez, tahmin, deney ve sonuç döngüsü. 1 kuruşluk sipariş toplamı farkıyla başlayan vakada Agans'ın 9 kuralını, shotgun debugging'in maliyetini ve debugging günlüğünü gerçek çıktılarla anlatıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Pazartesi sabahı muhasebeden bir ticket geliyor: "Ödeme sağlayıcısının mutabakat raporunda bazı siparişlerde tahsil edilen tutar faturadan 1 kuruş az. Örnek: #10482, tahsil edilen 178,46 TL, fatura 178,47 TL. Hepsinde değil, bazılarında." Siparişte üç satır var: 12,45 TL'lik bir kitap %10 indirimle, 89,90 TL'lik bir kitap indirimsiz, 45,50 TL'lik bir kitaptan iki adet %15 indirimle.
İki soru. Birincisi: 1 kuruşluk fark bu üç satırdan hangisinden geliyor? İkincisi: 10,00 ile 99,99 TL arasındaki 9.000 farklı fiyatın hepsine %10 indirim uygulasak, kaç fiyatta checkout'un hesabı ile faturanın hesabı birbirini tutmaz? Cevabı yazının ortasında, gerçek bir koşunun çıktısında bulacaksın.
Debugging Neden Bir Yöntem İşi?
Bu ticket'ı açan çoğu geliştiricinin ilk refleksi kodu açıp "nerede yanlış olabilir" diye düşünmeye başlamaktır. Birkaç dakika sonra elinde üç beş tahmin olur: belki ödeme sağlayıcısı yuvarlıyordur, belki kargo ücreti karışıyordur, belki double kullanan bir yer vardır. Sonra en makul görüneni değiştirir, deploy eder ve bekler. Ticket'taki kelimeye dikkat et: "bazen". Belirti her siparişte görünmüyorsa, değişiklikten sonraki iki günde şikâyet gelmemesi hiçbir şey kanıtlamaz.
Debugging (hata ayıklama) aslında bir arama problemidir: sistemde doğru davranış ile gözlenen davranışın ayrıldığı noktayı arıyorsun. Bu aramayı tahminle yaparsan şansa kalırsın; yöntemle yaparsan her adımda arama alanını küçültürsün. Bu seri o yöntemi anlatıyor: bu yazıda döngünün kendisi ve David J. Agans'ın kuralları, sonraki yazılarda reproduce etmek, daraltmak, stack trace okumak, debugger'ı ileri seviyede kullanmak, production'da log, dump ve trace ile teşhis koymak. Breakpoint, step ve call stack gibi temel debugger kavramlarını burada tekrar etmiyorum; onlar C# Temelleri serisinin "C# Hata Ayıklama: Breakpoint, Call Stack, Debug.Assert ve Derleyici Uyarıları" yazısında.
Önce Bak: Ticket'taki Siparişi Satır Satır Hesaplamak
Kafada teori kurmadan önce elimizdeki tek somut veriyi, #10482'yi alıp iki hesabı yan yana koyalım. Checkout tarafındaki PriceCalculator, production'daki hesabın sadeleştirilmiş hali; InvoiceCalculator ise fatura servisinin kullandığı hesabın C# karşılığı:
namespace Bookstore.Ordering;
public sealed record OrderLine(string Isbn, decimal UnitPrice, int Quantity, decimal DiscountRate);
public static class PriceCalculator
{
public static decimal DiscountedUnitPrice(OrderLine line) =>
Math.Round(line.UnitPrice * (1 - line.DiscountRate), 2);
public static decimal LineTotal(OrderLine line) => DiscountedUnitPrice(line) * line.Quantity;
public static decimal OrderTotal(IEnumerable<OrderLine> lines) => lines.Sum(LineTotal);
}
public static class InvoiceCalculator
{
public static decimal DiscountedUnitPrice(OrderLine line) =>
Math.Round(line.UnitPrice * (1 - line.DiscountRate), 2, MidpointRounding.AwayFromZero);
public static decimal OrderTotal(IEnumerable<OrderLine> lines) =>
lines.Sum(l => DiscountedUnitPrice(l) * l.Quantity);
}
Bu küçük console uygulaması siparişi iki hesapla da çalıştırıyor ve her satır için yuvarlanmamış ham değeri de yazıyor:
using System.Globalization;
using Bookstore.Ordering;
// çıktı makinenin culture'ından bağımsız olsun
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// ticket'taki #10482 numaralı sipariş
List<OrderLine> lines =
[
new("978-605-0000-01-1", UnitPrice: 12.45m, Quantity: 1, DiscountRate: 0.10m),
new("978-605-0000-02-8", UnitPrice: 89.90m, Quantity: 1, DiscountRate: 0.00m),
new("978-605-0000-03-5", UnitPrice: 45.50m, Quantity: 2, DiscountRate: 0.15m),
];
foreach (OrderLine line in lines)
{
decimal raw = line.UnitPrice * (1 - line.DiscountRate);
Console.WriteLine($"{line.Isbn} raw={raw} checkout={PriceCalculator.DiscountedUnitPrice(line)} invoice={InvoiceCalculator.DiscountedUnitPrice(line)}");
}
Console.WriteLine($"checkout total = {PriceCalculator.OrderTotal(lines)}");
Console.WriteLine($"invoice total = {InvoiceCalculator.OrderTotal(lines)}");
978-605-0000-01-1 raw=11.2050 checkout=11.20 invoice=11.21
978-605-0000-02-8 raw=89.9000 checkout=89.90 invoice=89.90
978-605-0000-03-5 raw=38.6750 checkout=38.68 invoice=38.68
checkout total = 178.46
invoice total = 178.47
İlk sorunun cevabı burada: fark yalnız ilk satırda. 12,45 TL'nin %10 indirimlisi tam 11,205; yani tam ortada, iki kuruşun arasında. Checkout bunu 11,20'ye, fatura 11,21'e yuvarlıyor. Üçüncü satırın ham değeri de 38,675 ile tam ortada, ama orada iki hesap aynı sonucu veriyor. "Bazen" kelimesinin ilk ipucu bu.
Dikkat et, henüz hiçbir şey düzeltmedik ve hiçbir şeyi "kesin" ilan etmedik. Sadece baktık. Ama bakmak, sabah kafamızda dönen üç teoriden ikisini (ödeme sağlayıcısının yuvarlaması, kargo ücreti) tek çalıştırmada eledi: farkı biz üretiyoruz, sağlayıcı ise ona gönderdiğimiz tutarı tahsil ediyor.
Döngü: Gözlem, Hipotez, Tahmin, Deney, Sonuç

Bilimsel yöntem debugging'e neredeyse hiç değiştirilmeden uyar. Gözlem: ne oluyor, hangi koşulda oluyor, hangi koşulda olmuyor. Hipotez: bunu açıklayabilecek tek ve test edilebilir bir cümle. Tahmin: hipotez doğruysa henüz bakmadığın bir yerde ne görmen gerekir. Deney: o tahmini sınayan en ucuz ölçüm. Sonuç: tahmin tuttu mu? Tutmadıysa hipotez elenir ve döngü yeni bir hipotezle döner; tuttuysa bir sonraki adıma, düzeltmeye geçersin.
Ayrımı yapan adım tahmindir. "Yuvarlamayla ilgili bir şey" bir hipotez değil, bir his. Test edilebilir hali şu: "Checkout, tam ortadaki değerleri MidpointRounding.ToEven ile yuvarlıyor; fatura AwayFromZero ile." Bu cümle bir tahmin üretir: fark yalnız ham değeri tam ortada olan ve yuvarlanınca ikinci ondalığı çift kalacak tutarlarda çıkmalı. 11,205 bu sınıfta (ikinci ondalık 0, çift); 38,675 değil (ikinci ondalık 7, tek; iki kural da yukarı yuvarlar). Tahmini kâğıt üstünde değil, kodla sınayalım:
// tahmin: yalnız ikinci ondalığı çift olan midpoint değerler ayrışır
int total = 0, differ = 0;
List<decimal> samples = [];
for (decimal price = 10.00m; price <= 99.99m; price += 0.01m)
{
OrderLine probe = new("probe", price, 1, 0.10m);
total++;
if (PriceCalculator.DiscountedUnitPrice(probe) != InvoiceCalculator.DiscountedUnitPrice(probe))
{
differ++;
if (samples.Count < 4) samples.Add(price);
}
}
Console.WriteLine($"prices tried = {total}, differ = {differ}, first = {string.Join(", ", samples)}");
prices tried = 9000, differ = 450, first = 10.05, 10.25, 10.45, 10.65
İkinci sorunun cevabı: 9.000 fiyattan 450'si, yani tam %5. İlk dört örnek de tahmine uyuyor: 10,05'in %10 indirimlisi 9,045 (ortada, ikinci ondalık 4), 10,25'inki 9,225, 10,45'inki 9,405. Arada kalan 10,15 ise 9,135 ediyor; ortada ama ikinci ondalık 3, iki kural da 9,14 veriyor. Tahmin tuttu. Bu, hatanın kodun hangi satırında olduğunu ve ne zaman girdiğini henüz söylemiyor; onlar bu serinin sonraki iki yazısının işi. Ama artık elimizde "rastgele bir tutarsızlık" yok; ne zaman olduğunu önceden söyleyebildiğimiz bir davranış var.
.NET dokümanı bunu açıkça yazıyor: Math.Round(Decimal, Int32) tam ortadaki değerleri en yakın çift sayıya yuvarlar. Buna banker's rounding de denir. Birçok geliştiricinin okulda öğrendiği "5 ve yukarısı yukarı" kuralı ise ayrı bir parametre ister: MidpointRounding.AwayFromZero.
Shotgun Debugging'in Gerçek Maliyeti
Aynı ticket'ı yöntemsiz çözmeye çalışan birini düşün. Toplamı bir kez daha Math.Round(total, 2) ile yuvarlıyor, bir yerde double görüp decimal'a çeviriyor, ödeme sağlayıcısına giden tutarı string'e çevirirken format'ı değiştiriyor ve üçünü tek commit'te deploy ediyor. Buna shotgun debugging deniyor: hedefe saçma atış yapıp bir saçmanın tutmasını ummak.
Bunun bedeli üç kalemde ödenir. Birincisi, belirti zaten %5 civarında bir sıklıkla görünüyorsa, deploy sonrası bir iki gün şikâyet gelmemesi tesadüf olabilir; "düzeldi" sanıp ticket'ı kapatırsın. İkincisi, üç değişiklikten biri gerçekten etkiliyse hangisi olduğunu bilmezsin; diğer ikisi koda gereksiz ve riskli değişiklik olarak kalır. Üçüncüsü, hiçbiri etkili değilse sistem artık ticket açıldığı andaki sistem değildir; reproduce etmeye çalıştığın davranış, üzerine eklenmiş üç yeni değişkenle değişmiştir.
Agans'ın 9 Kuralı: Hatayı Yakalamak
David J. Agans'ın 2002 tarihli "Debugging: The 9 Indispensable Rules for Finding Even the Most Elusive Software and Hardware Problems" kitabı bu disiplini dokuz kısa kurala indirir. Kurallar donanımdan yazılıma her yerde geçerli; aşağıda her birini .NET ve Angular dünyasından bir örnekle anlatıyorum.
1. Sistemi anla (Understand the System)
Ne yaptığını bilmediğin bir sistemi debug edemezsin. Bu vakada "anlamak" iki şey demek: Math.Round'un dokümanını okumak ve sipariş toplamının sistemde iki ayrı yerde, checkout'ta ve fatura servisinde hesaplandığını bilmek. Angular tarafında karşılığı, bir request'in hangi interceptor'lardan hangi sırayla geçtiğini bilmektir; bir header'ın neden kaybolduğunu ancak o zincirle açıklayabilirsin.
2. Hatayı tekrarlat (Make It Fail)
Hatayı istediğin an tetikleyebiliyorsan yarı yoldasın. Ticket'taki sipariş numarası altın değerindedir: o siparişin satırlarıyla hatayı makinende üretebiliyorsun. Bunu bir adım ileri taşıyıp hatayı kırmızı bir unit test'e çevirmek bir sonraki yazının konusu.
3. Düşünmeyi bırak ve bak (Quit Thinking and Look)
Kafandaki model ile gerçek sistem ayrıştığı için bug var; o yüzden kafandaki modelle akıl yürütmek seni bug'a götürmez. Yukarıda ham değeri yazdırmak tam olarak buydu. Bakmanın araçları ayrıntılı log, debugger, tracepoint, network panelidir. Bakmak için yazdığın bir satır log, yarım saatlik tartışmadan ucuzdur.
4. Böl ve fethet (Divide and Conquer)
Hatayı içeren aralığı her adımda ikiye böl. Aralık bazen satırlardır (siparişin hangi satırı?), bazen katmanlardır (Angular mı, API (Application Programming Interface) mi, veritabanı mı?), bazen commit'lerdir (hangi commit'ten sonra?). Bu serinin 03 numaralı yazısı tamamen bu kurala ayrılmış: binary search, git bisect run ve delta debugging.
5. Tek seferde tek şey değiştir (Change One Thing at a Time)
Shotgun debugging'in tam tersi. Bir değişiklik yap, sonucu ölç, işe yaramadıysa geri al. Aynı anda Math.Round çağrısını, NuGet paket sürümünü ve configuration'ı değiştirirsen, sonuç ne olursa olsun ondan bir şey öğrenemezsin.
Agans'ın 9 Kuralı: Disiplini Korumak
6. Kayıt tut (Keep an Audit Trail)
Ne denediğini, hangi sırayla denediğini ve ne gördüğünü yaz. Hafıza, özellikle saat 18.00'den sonra, güvenilmez bir kayıt aracıdır. Aşağıda bu ticket için tutulmuş gerçekçi bir debugging günlüğü var:
| Saat | Hipotez | Deney | Sonuç |
|---|---|---|---|
| 10.05 | Ödeme sağlayıcısı tutarı kendi yuvarlıyor | Sağlayıcıya giden request'teki amount alanına bakmak | Elendi: 178,46'yı biz göndermişiz |
| 10.20 | Kargo ücreti toplamı bozuyor | #10482'nin kargo satırı | Elendi: kargo ücretsiz |
| 10.40 | Satır bazında yuvarlama farkı var | Siparişi iki hesapla satır satır çalıştırmak | Tutuldu: fark yalnız 11,205'li satırda |
| 11.00 | Checkout ToEven, fatura AwayFromZero yuvarlıyor | 9.000 fiyatla tahmini sınamak | Tutuldu: 450 fark, hepsi tahmine uyuyor |
Elenen hipotezleri de yazmak önemli. İki gün sonra aynı ticket'a dönen bir ekip arkadaşının "acaba ödeme sağlayıcısı mı" diye yarım saat harcamasını önleyen satır, ilk satırdır. Günlük, bu serinin 11. yazısında yazacağımız postmortem'in zaman çizelgesinin de hammaddesi olacak.
7. Fişi kontrol et (Check the Plug)
En bariz varsayımları sorgula. Production'da çalışan sürüm, düşündüğün sürüm mü? Angular uygulaması yeni bundle'ı mı yüklüyor, yoksa tarayıcı cache'teki eski index.html'i mi kullanıyor? API, staging veritabanına mı bağlı? Debug ettiğin process, gerçekten request'i alan process mi? Uygulamanın sürümünü ve commit hash'ini dönen basit bir health endpoint'i, bu kuralı saniyeler içinde uygulamanı sağlar.
8. Taze göz al (Get a Fresh View)
Probleme fazla gömüldüğünde aynı varsayımı tekrar tekrar doğru kabul edersin. Bir ekip arkadaşına anlat; kimse yoksa masandaki lastik ördeğe anlat. Rubber duck debugging, Andrew Hunt ve David Thomas'ın The Pragmatic Programmer (1999) kitabıyla yaygınlaşan bir tekniktir: problemi yüksek sesle, adım adım anlatırken "burada Math.Round zaten iki haneye yuvarlıyor, yani..." diye başladığın cümlede takılırsın. Takıldığın yer genellikle sorgulamadığın varsayımdır.
Ne zaman yardım istemeli? Pratik bir eşik: aynı hipotez etrafında bir saatten fazla dönüyorsan ve günlüğe yeni satır eklemiyorsan, dur ve birine anlat. Yardım isterken günlüğü paylaş; "çalışmıyor" yerine "şunu denedim, şunu gördüm, şunu bekliyordum" demek karşındakinin zamanını da kurtarır.
9. Düzeltmediysen düzelmemiştir (If You Didn't Fix It, It Ain't Fixed)
Bug kendiliğinden kaybolduysa düzelmemiştir; sadece saklanıyordur. Düzelttiğini kanıtlamanın yolu: önce hatayı gösteren testi kırmızı görmek, düzeltmeyi yapmak, testi yeşil görmek ve düzeltmeyi geri alınca testin tekrar kırmızıya döndüğünü doğrulamak. Bu son adım, gerçekten neyin düzelttiğini bildiğinin kanıtıdır.
Üretimde Dikkat
Production'da debugging'in kuralları aynı, ama deney yapmanın bedeli farklı. Production'da "bir şey değiştirip bakmak" gerçek müşterileri etkiler; bu yüzden deneyleri mümkün olduğunca production verisinin kopyasıyla, kendi makinende ya da bir test ortamında yap. Production'dan alacağın veri (sipariş satırları, log, dump) çoğu zaman kişisel veri içerir; ticket'a yapıştırmadan önce müşteri adını, adresini ve kart bilgisini temizle.
İkinci nokta, etkiyi ölçmek. Bu vakada fark sipariş başına 1 kuruş; küçük görünüyor. Ama tahminimizin söylediği sıklıkla (indirimli satırların bir bölümünde) günde onlarca siparişi etkileyebilir ve muhasebe her farkı tek tek açıklamak zorunda kalır. Önceliği belirtinin büyüklüğü değil, etkinin toplamı belirler. Etkiyi hesaplamayı 11. yazıdaki postmortem'de yapacağız.
Üçüncüsü, düzeltmeyi deploy etmek geçmişi düzeltmez. Hatalı tutarla tahsil edilmiş siparişlerin listesi, muhasebeye ayrı bir iş olarak çıkarılmalı.
Sık Düşülen Tuzaklar
İlk tuzak, hipotezi gerçek sanmak. "Kesin yuvarlamadır" diyerek deneyi atlamak, sağlam görünen bir hipotez yanlış çıktığında saatler kaybettirir. Hipotez, tahmini tutana kadar hipotezdir.
İkinci tuzak, belirtiyi düzeltmek. Toplamı sonradan bir kuruş artıran bir "düzeltme" #10482'yi düzeltir ama 38,675'li satır gibi zaten doğru hesaplanan siparişleri bozar. Sebebi bulmadan yapılan düzeltme, bir sonraki bug'ın kaynağıdır.
Üçüncü tuzak, "bende çalışıyor". Senin makinende çalışan, bu davranışı tetikleyen veriyi denemediğin için çalışıyor olabilir. Bu vakada 12,45 yerine 12,40 ile denemek "her şey doğru" sonucunu verir.
Dördüncü tuzak, günlük tutmamak ve elenen hipotezleri unutmak. Ekipte her gelen aynı yanlış yoldan bir kez daha geçer.
Beşinci tuzak, yardım istemeyi ertelemek. Bir saat tek başına dönmek kahramanlık değildir; günlükle birlikte sorulan beş dakikalık bir soru çoğu zaman o saati kurtarır.
Küçük Bir Deneme
★☆☆ Yukarıdaki tarama döngüsünü indirim oranı %15 ve %25 için çalıştır. Kaç fiyat ayrışıyor? Sonucu çalıştırmadan önce tahmin et ve tahminini günlüğüne yaz.
★★☆ Son çözdüğün bir bug için geriye dönük bir debugging günlüğü yaz: saat, hipotez, deney, sonuç. Kaç hipotez eledin ve hangisi gereksiz zaman aldı? Hangi Agans kuralını atlamıştın?
★★★ PriceCalculator'a MidpointRounding.AwayFromZero'yu ekle ve iki hesabın 9.000 fiyatta da aynı sonucu verdiğini göster. Sonra değişikliği geri al ve farkın tekrar 450'ye döndüğünü doğrula. Dokuzuncu kuralı kendi elinle uygulamış olacaksın.
Bir sonraki yazıda bu ticket'ı ciddiye alıp bug'ı düzgünce reproduce ediyoruz: kullanıcıdan hangi bilgiyi almalı, culture ve timezone gibi ortam farkları nasıl kontrol edilir, sipariş nasıl tek satırlık bir minimal repro'ya indirilir ve düzeltmeden önce neden kırmızı bir xUnit testi yazılır.