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

Clean Code'da Fonksiyonlar: Küçük Method, Tek Soyutlama Seviyesi ve Guard Clause

Mert Özen 7 Kas 2026 17 dk 0 görüntülenme
Clean Code'da Fonksiyonlar: Küçük Method, Tek Soyutlama Seviyesi ve Guard Clause

Bir method tek bir iş yapmalı ve tek bir soyutlama seviyesinde kalmalı: legacy OrderService'teki 136 satırlık ProcessData guard clause, step-down rule, parameter object ve CQS (Command-Query Separation) ile bölününce Cognitive Complexity 92'den en yüksek 5'e iniyor ve dokuz senaryoda davranış aynı kalıyor. Yazı flag argument'ları, out yerine record dönmeyi, ThrowIfNull gibi guard helper'ları ve aşırı bölmenin bedelini (Ousterhout'un shallow method eleştirisi) gerçek çıktılarla anlatıyor.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un ürün sayfası, bir kitabın stokta olup olmadığını legacy OrderService'teki CheckStock ile soruyor. Sayfada iki yer bu bilgiye ihtiyaç duyuyor: "Stokta" rozeti ve "Sepete ekle" butonu. "Emma"nın stoğunda 3 kitap var ve kullanıcı 2 adet seçmiş:

using Bookstore.Legacy;

var service = new OrderService();
Book emma = Db.Books.First(b => b.Title == "Emma");

// Ürün sayfası: rozet için bir kez, "Sepete ekle" butonu için bir kez sorar
bool showInStockBadge = service.CheckStock(emma.Isbn, 2);
bool enableAddToCart = service.CheckStock(emma.Isbn, 2);

Console.WriteLine($"badge: {showInStockBadge}, button: {enableAddToCart}, stock now: {emma.Stock}");

CheckStock'un içi:

public bool CheckStock(string isbn, int qty)
{
    var b = Db.Books.FirstOrDefault(x => x.Isbn == isbn);
    if (b == null) return false;
    if (b.Stock >= qty)
    {
        b.Stock -= qty;
        return true;
    }
    return false;
}

Rozet ve buton aynı soruya aynı cevabı vermeli. Program ne yazar? Sayfa yenilendiğinde ne olur?

Soru Soran Method Stoğu Düşürüyor

Gerçek çıktı:

badge: True, button: False, stock now: 1

Rozet "stokta" diyor, buton pasif. Kimse sipariş vermedi ama stok 3'ten 1'e indi; sayfayı bir kez daha yenilersen ikisi de False olur. Hiçbir exception yok, hiçbir log yok. Ürün sayfası birkaç saat açık kalınca "Emma" stokta görünmez hâle gelir ve depodaki üç kitap satılmayı bekler.

Sebep, CheckStock adının bir soru vaat etmesi ama aslında bir komut çalıştırması. Bertrand Meyer'in (Eiffel dilinin yaratıcısı, Object-Oriented Software Construction kitabının yazarı) Command-Query Separation (CQS) ilkesi tam olarak bunu yasaklar: bir method ya bir şey yapar (command) ya bir şey cevaplar (query), ikisini birden değil. Query'yi istediğin kadar çağırabilirsin, sistemin durumu değişmez. Robert C. Martin de (Clean Code kitabının yazarı) bunu Clean Code'un fonksiyon kuralları arasında sayar ve ikinci baskıda Function Heuristics bölümünde "Command Query Separation" başlığı olarak duruyor.

Legacy dosyada aynı hastalığın başka örnekleri de var. IsValid(Order o) siparişin durumunu 2 yapıyor; ProcessData ise sipariş reddedilse bile sipariş numarası harcıyor, çünkü o.Id = Db.NextOrderId++ satırı doğrulamaların ortasında. Bunu denedim: önce bilinmeyen bir ISBN (International Standard Book Number), sonra geçersiz bir kuponla iki sipariş reddedildi, üçüncü başarılı sipariş 1002 numarasını aldı. Muhasebe "1000 ve 1001 numaralı siparişler nerede" diye sorduğunda cevap bir side effect'te saklı.

Düzeltmesi iki method:

// Query: yalnız soru sorar, hiçbir şeyi değiştirmez
private static bool HasStock(Book book, int quantity) => book.Stock >= quantity;

// Command: stoğu değiştirir, bir şey döndürmez
private static void ReserveStock(Book book, int quantity)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
    if (!HasStock(book, quantity)) throw new InvalidOperationException($"Not enough stock: {book.Title}");
    book.Stock -= quantity;
}

Ürün sayfası HasStock'u istediği kadar çağırır. ReserveStock yalnız sipariş tamamlanırken çağrılır ve stok yetmiyorsa sessizce false dönmek yerine exception fırlatır, çünkü bu noktaya gelinmişse kontrol zaten yapılmış olmalıydı.

Küçük ve Tek Bir İş

Martin'in fonksiyonlar için ilk iki kuralı "küçük olsun" ve "tek bir iş yapsın". İkinci baskı bu konuya beş ayrı bölüm ayırıyor; "One Thing" bölümünün alt başlıklarından biri "What Are Large Functions Anyway?". Kural satır sayısıyla değil, işle ilgili. Peki "tek iş" nasıl ölçülür? Martin'in önerdiği test şu: method'un içinden, adı yalnızca gövdenin tekrarı olmayan bir method daha çıkarabiliyorsan, method birden fazla iş yapıyordur.

ProcessData'yı bu gözle okuyunca on bir iş sayıyorum: müşteriyi bul, sepetin boş olmadığını kontrol et, müşterinin engelli olmadığını kontrol et, her kitabı bul ve stoğunu kontrol et, birim fiyatı formata göre hesapla, kupon, VIP (Very Important Person), toplu sipariş ve hafta sonu indirimlerini topla, kargo ücretini hesapla, ödemeyi al, stoğu düş, sadakat puanı ver, e-posta gönder. Bir de log yazıyor. Her birinin değişmek için ayrı bir sebebi var: kampanya ekibi indirimleri, lojistik kargo ücretini, ödeme ekibi sağlayıcıyı değiştirir. Hepsi aynı 136 satırda olduğu için her değişiklik ötekileri bozma riski taşıyor.

Guard Clause ile Arrow Code'u Düzleştirmek

ProcessData'nın başı ve sonu şöyle:

public int ProcessData(int cId, Dictionary<string, int> d, string strCoupon, bool flag, bool sendMail, int t)
{
    LastError = null;
    Customer c = null;
    foreach (var x in Db.Customers)
    {
        if (x.Id == cId)
        {
            c = x;
        }
    }
    if (c != null)
    {
        if (d != null && d.Count > 0)
        {
            if (c.IsBlocked == false)
            {
                // ... asıl iş: 90 satır fiyat, ödeme, stok, puan, e-posta ...
                return o.Id;
            }
            else
            {
                LastError = "Customer blocked";
                return -6;
            }
        }
        else
        {
            LastError = "Empty cart";
            return -1;
        }
    }
    else
    {
        LastError = "Customer not found";
        return -7;
    }
}

Bu şekle arrow code denir: girinti bir ok ucu gibi sağa doğru büyür. Okuyucu "müşteri bulunamazsa ne olur" sorusunun cevabını method'un en altında, açılan parantezi kapanan parantezle eşleştirerek bulur. Asıl iş (happy path) üç seviye içeride ve her else, ait olduğu if'ten onlarca satır uzakta.

Guard clause bu sırayı tersine çevirir: beklenmeyen durumları method'un başında tek tek eleyip hemen döner, geriye kalan kod tek seviyede asıl işi yapar. Aynı başlangıcın yeni hâli:

Customer? customer = FindCustomer(request.CustomerId);
if (customer is null) return Reject(new Rejection(-7, "Customer not found"));

Rejection? rejection = CheckCanOrder(customer, request);
if (rejection is not null) return Reject(rejection);

Her ret sebebi kendi satırında, mesajıyla yan yana. "Engelli müşteri ne alır" sorusunun cevabı artık on satır aşağıda değil, kontrolün hemen yanında. Bu yazıda return code'ların kendisine dokunmuyorum, -7 hâlâ -7; onları exception ve Result pattern'e taşımak error handling yazısının konusu.

Tek Soyutlama Seviyesi ve Step-Down Rule

Guard clause'lar girintiyi düzeltir, ama method hâlâ hem "siparişi tamamla" gibi üst seviye kararlar hem b.Stock - oi.Qty gibi alt seviye ayrıntılar içeriyorsa okuyucu sürekli seviye değiştirir. Martin'in kuralı (one level of abstraction per function): bir method'daki tüm ifadeler aynı soyutlama seviyesinde olsun. Step-down rule da bunun okuma sırası: dosya yukarıdan aşağıya bir hikâye gibi okunsun, her method bir alt seviyeye iner ve onu çağıran method'un hemen altında durur.

ProcessData'yı bu iki kurala göre böldüm. Fiyat hesabını bir önceki yazıda isimlerini düzelttiğimiz OrderPricing'e, e-postayı ayrı bir OrderNotifier'a taşıdım; sipariş vermenin kendisi şöyle:

using Bookstore.Legacy;
using Vendor.Payments;

namespace Bookstore.Ordering;

public sealed record PlaceOrderRequest(
    int CustomerId,
    IReadOnlyDictionary<string, int> QuantitiesByIsbn,
    string? CouponCode,
    bool GiftWrap,
    ShippingMethod ShippingMethod);

public sealed record Rejection(int Code, string Message);

public sealed class OrderPlacementService(PaymentClient paymentClient, OrderPricing pricing)
{
    private const string ApprovedPaymentCode = "00";

    public int PlaceOrder(PlaceOrderRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        Customer? customer = FindCustomer(request.CustomerId);
        if (customer is null) return Reject(new Rejection(-7, "Customer not found"));

        Rejection? rejection = CheckCanOrder(customer, request);
        if (rejection is not null) return Reject(rejection);

        Order order = CreateOrder(customer, request);
        ChargeResult payment = paymentClient.Charge(customer.CardToken, ToMinorUnits(order.Total), "TRY");
        if (payment.Code != ApprovedPaymentCode) return RejectPayment(order, payment);

        CompleteOrder(order, payment.TransactionId);
        return order.Id;
    }

    private Rejection? CheckCanOrder(Customer customer, PlaceOrderRequest request)
    {
        if (request.QuantitiesByIsbn.Count == 0) return new Rejection(-1, "Empty cart");
        if (customer.IsBlocked) return new Rejection(-6, "Customer blocked");

        foreach ((string isbn, int quantity) in request.QuantitiesByIsbn)
        {
            Book? book = FindBook(isbn);
            if (book is null) return new Rejection(-2, "Book not found: " + isbn);
            if (!HasStock(book, quantity)) return new Rejection(-3, "Not enough stock: " + book.Title);
        }

        return pricing.IsKnownCoupon(request.CouponCode) ? null : new Rejection(-4, "Invalid coupon");
    }

    private Order CreateOrder(Customer customer, PlaceOrderRequest request)
    {
        List<OrderItem> items = [.. request.QuantitiesByIsbn.Select(line => CreateItem(FindBook(line.Key)!, line.Value))];
        OrderTotals totals = pricing.CalculateTotals(customer, items, request.CouponCode, request.GiftWrap, request.ShippingMethod);
        return new Order
        {
            Id = Db.NextOrderId++,
            Customer = customer,
            Date = DateTime.Now,
            Items = items,
            Discount = totals.Discount,
            Shipping = totals.ShippingFee,
            Total = totals.Total
        };
    }

    private OrderItem CreateItem(Book book, int quantity) =>
        new() { Book = book, Qty = quantity, Price = pricing.UnitPriceOf(book) };

    private void CompleteOrder(Order order, string transactionId)
    {
        order.PaymentRef = transactionId;
        foreach (OrderItem item in order.Items) ReserveStock(item.Book, item.Qty);
        order.Status = 1;
        Db.Orders.Add(order);
        AwardLoyaltyPoints(order.Customer, order.Total);
    }

    private static int RejectPayment(Order order, ChargeResult payment)
    {
        order.Status = 9;
        Db.Orders.Add(order);
        return Reject(new Rejection(-5, "Payment failed: " + payment.Message));
    }

    private static void AwardLoyaltyPoints(Customer customer, decimal orderTotal)
    {
        customer.Points += (int)(orderTotal / 10);
        if (customer.Type == "vip") customer.Points += 50;
    }

    // Query: yalnız soru sorar, hiçbir şeyi değiştirmez
    private static bool HasStock(Book book, int quantity) => book.Stock >= quantity;

    // Command: stoğu değiştirir, bir şey döndürmez
    private static void ReserveStock(Book book, int quantity)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        if (!HasStock(book, quantity)) throw new InvalidOperationException($"Not enough stock: {book.Title}");
        book.Stock -= quantity;
    }

    private static Customer? FindCustomer(int customerId) => Db.Customers.FirstOrDefault(c => c.Id == customerId);

    private static Book? FindBook(string isbn) => Db.Books.FirstOrDefault(b => b.Isbn == isbn);

    private static long ToMinorUnits(decimal amount) => (long)(amount * 100);

    private static int Reject(Rejection rejection)
    {
        OrderService.LastError = rejection.Message;
        return rejection.Code;
    }
}

PlaceOrder'ı bir içindekiler sayfası gibi okuyabiliyorsun: müşteriyi bul, sipariş verebilir mi bak, siparişi oluştur, ödemeyi al, ödeme reddedildiyse reddet, siparişi tamamla. Stoğun nasıl düştüğünü merak edersen CompleteOrder'a, oradan ReserveStock'a inersin. Merak etmezsen inmezsin; bu, büyük method'da sahip olmadığın bir seçenek.

Fiyat hesabı da aynı yapıda; CalculateTotals indirimleri isimleriyle topluyor ve her indirim kendi tek satırlık method'unda:

using Bookstore.Legacy;

namespace Bookstore.Ordering;

public enum ShippingMethod
{
    Standard = 1,
    Express = 2,
    StorePickup = 3
}

public sealed record OrderTotals(decimal Subtotal, decimal Discount, decimal ShippingFee, decimal Total);

public sealed class OrderPricing
{
    private const string WelcomeCouponCode = "WELCOME10";
    private const string SummerCouponCode = "SUMMER25";
    private const decimal TaxRate = 0.10m;
    private const decimal GiftWrapFee = 12.5m;
    private const decimal FreeShippingThreshold = 500m;

    public bool IsKnownCoupon(string? couponCode) =>
        string.IsNullOrEmpty(couponCode) || couponCode.ToUpper() is WelcomeCouponCode or SummerCouponCode;

    public decimal UnitPriceOf(Book book) => book.Type switch
    {
        "ebook" => book.Price * 0.8m,
        "audio" => book.Price * 0.9m,
        _ => book.Price
    };

    public OrderTotals CalculateTotals(
        Customer customer, IReadOnlyList<OrderItem> items, string? couponCode, bool giftWrap, ShippingMethod shippingMethod)
    {
        decimal subtotal = items.Sum(item => item.Price * item.Qty);
        decimal discount = CouponDiscount(couponCode, subtotal)
            + VipDiscount(customer, subtotal)
            + BulkOrderDiscount(items.Count)
            + WeekendDiscount(subtotal);
        decimal shippingFee = ShippingFee(subtotal - discount, shippingMethod, customer.Address.City.Name)
            + (giftWrap ? GiftWrapFee : 0m);
        decimal total = Math.Round((subtotal - discount) * (1 + TaxRate) + shippingFee, 2);
        return new OrderTotals(subtotal, discount, shippingFee, total);
    }

    private static decimal CouponDiscount(string? couponCode, decimal subtotal) => couponCode?.ToUpper() switch
    {
        WelcomeCouponCode => subtotal * 0.1m,
        SummerCouponCode => 25m,
        _ => 0m
    };

    private static decimal VipDiscount(Customer customer, decimal subtotal) =>
        customer.Type.ToUpper() == "VIP" ? subtotal * 0.05m : 0m;

    private static decimal BulkOrderDiscount(int lineCount) => lineCount > 5 ? 15m : 0m;

    private static decimal WeekendDiscount(decimal subtotal) =>
        DateTime.Now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday ? subtotal * 0.02m : 0m;

    private static decimal ShippingFee(decimal discountedSubtotal, ShippingMethod method, string cityName) => method switch
    {
        ShippingMethod.Standard when discountedSubtotal >= FreeShippingThreshold => 0m,
        ShippingMethod.Standard when cityName == "Istanbul" => 29.90m,
        ShippingMethod.Standard => 39.90m,
        ShippingMethod.Express => 89.90m,
        ShippingMethod.StorePickup => 0m,
        _ => throw new ArgumentOutOfRangeException(nameof(method), method, "Unknown shipping method")
    };
}

Davranışın değişmediğini yine ölçtüm. Legacy ProcessData ile yeni PlaceOrder'ı, ikisi başarılı yedisi ret olan dokuz senaryoda yan yana çalıştırdım:

ok, standard      legacy: #1000 total 490.25                 refactored: #1000 total 490.25
ok, express+gift  legacy: #1000 total 1220.44                refactored: #1000 total 1220.44
unknown customer  legacy: -7 Customer not found              refactored: -7 Customer not found
empty cart        legacy: -1 Empty cart                      refactored: -1 Empty cart
blocked           legacy: -6 Customer blocked                refactored: -6 Customer blocked
unknown book      legacy: -2 Book not found: 000             refactored: -2 Book not found: 000
no stock          legacy: -3 Not enough stock: Emma          refactored: -3 Not enough stock: Emma
bad coupon        legacy: -4 Invalid coupon                  refactored: -4 Invalid coupon
card declined     legacy: -5 Payment failed: Do not honor    refactored: -5 Payment failed: Do not honor

Tek bilinçli fark, yukarıda anlattığım sipariş numarası: yeni kod numarayı yalnız sipariş gerçekten oluşunca alıyor. Bu karşılaştırmayı 14. yazıda gerçek characterization test'lerine çevireceğiz.

Parametreler: Flag Argument, Parameter Object ve out

ProcessData altı parametre alıyordu ve bunların ikisi bool. Martin'e göre ideal parametre sayısı sıfır; bir ve iki makul, üçten mümkünse kaçınılmalı, daha fazlası çok özel bir gerekçe ister. Bu katı bir sayı olarak değil, bir uyarı işareti olarak okunmalı: çok parametre ya method'un çok iş yaptığını ya da parametrelerin aslında bir kavram oluşturduğunu gösterir.

Flag argument, method'un iki farklı şey yapabildiğini imzasında ilan etmesidir. sendMail parametresi ProcessData'ya "sipariş ver ve belki e-posta gönder" dedirtir. Çözüm iki method: sipariş vermek bir iş, onay e-postası göndermek başka bir iş. Çağıran ikisini sırayla çağırır:

using System.Text;
using Bookstore.Legacy;

namespace Bookstore.Ordering;

public sealed class OrderNotifier
{
    public void SendConfirmation(Order order)
    {
        ArgumentNullException.ThrowIfNull(order);
        Mailer.Send(order.Customer.Email, $"Order #{order.Id}", BuildConfirmationBody(order), false);
    }

    private static string BuildConfirmationBody(Order order)
    {
        var body = new StringBuilder();
        body.Append($"Dear {order.Customer.Name},\n");
        body.Append($"Your order #{order.Id} has been received.\n");
        foreach (OrderItem item in order.Items)
            body.Append($" - {item.Book.Title} x{item.Qty} = {item.Price * item.Qty:0.00}\n");
        body.Append($"Shipping: {order.Shipping:0.00}\n");
        body.Append($"Total: {order.Total:0.00} TRY\n");
        return body.ToString();
    }
}
int orderId = placement.PlaceOrder(request);
if (orderId > 0) notifier.SendConfirmation(Db.Orders.Last());

Legacy'deki Report(DateTime from, DateTime to, bool csv) aynı problemin bir başka hâli; doğru şekli RenderReportAsCsv ve RenderReportAsText. CancelOrder(int id, bool refund, bool notify) ise iki flag taşıyor ve dört farklı davranışı tek isim altında saklıyor.

Her bool parametre flag argument değildir. Hediye paketi (flag) bir davranış seçmiyor, siparişin bir özelliğini taşıyor. Onu method'u ikiye bölerek değil, siparişin verisinin parçası yaparak düzelttim: PlaceOrderRequest record'u müşteri, sepet, kupon, hediye paketi ve kargo yöntemini tek bir parameter object'te topluyor. Kazancı yalnız kısa imza değil; bu beş değer zaten hep birlikte dolaşıyordu ve artık bir adları var.

out parametreleri de aynı aileden. Legacy'deki TryCalc(Order o, out decimal subtotal, out decimal tax, out string err) dört değer döndürmek için üç out kullanıyor ve çağıranı önce değişken tanımlamaya zorluyor. Bunun yerine bir record döndürmek hem okunur hem genişler: OrderTotals(decimal Subtotal, decimal Discount, decimal ShippingFee, decimal Total). .NET'in kendi TryParse method'larındaki out bir istisna; "başarılı mı" ve "değer" ikilisi için yerleşik ve dilin pattern'leriyle iyi çalışan bir contract.

Guard Helper'lar: ThrowIfNull ve Arkadaşları

İki tür guard'ı ayırmak gerekiyor. Engelli müşteri, boş sepet, geçersiz kupon iş kuralı gereği reddedilir; bunlar beklenen durumlardır ve yukarıdaki Rejection'la dönülür. null bir request ya da 0 adetlik bir stok ayırma ise çağıranın hatasıdır, bir bug'dır; bunun için exception fırlatılır. .NET 6'dan beri bu ikinci tür için hazır helper'lar var ve her biri parametre adını CallerArgumentExpression ile kendisi bulur:

Try(() => ReserveStock(null!, 2));
Try(() => ReserveStock("978-0441013593", 0));
Try(() => ReserveStock("   ", 2));

static void ReserveStock(string isbn, int quantity)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(isbn);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
    Console.WriteLine($"Reserved {quantity} x {isbn}");
}

static void Try(Action action)
{
    try { action(); }
    catch (ArgumentException ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
}
ArgumentNullException: Value cannot be null. (Parameter 'isbn')
ArgumentOutOfRangeException: quantity ('0') must be a non-negative and non-zero value. (Parameter 'quantity')
Actual value was 0.
ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'isbn')

ArgumentNullException.ThrowIfNull .NET 6'da, ArgumentException.ThrowIfNullOrWhiteSpace ve ArgumentOutOfRangeException.ThrowIfNegativeOrZero gibi karşılaştırma helper'ları .NET 8'de geldi. Eskiden üç satır tutan if (x == null) throw new ArgumentNullException(nameof(x)); artık tek satır ve parametre adını yanlış yazma ihtimali yok. Nullable reference types açıkken ThrowIfNull derleyiciye "bu satırdan sonra null değil" bilgisini de verir.

Cognitive Complexity ile Ölçmek

"Bu method fazla karmaşık" bir his; ölçülebilir hâli için iki metrik yaygın. Cyclomatic complexity (Thomas McCabe'in 1976 tarihli makalesinde tanımladığı metrik) bir method'daki bağımsız yolların sayısıdır ve aslında test etmek için kaç senaryo gerektiğini söyler. Cognitive Complexity ise SonarSource'un (SonarQube gibi statik analiz araçlarını geliştiren şirket) G. Ann Campbell'in yazdığı bir white paper ile tanımladığı metrik ve "bunu anlamak ne kadar zor" sorusuna cevap vermeye çalışır. White paper'daki kurallar kısaca şöyle: her if, else if, else, döngü, catch, switch ve ardışık mantık operatörü dizisi +1 ekler; bunlar iç içe girdikçe her seviye için ayrıca +1 eklenir; method'un kendisi ve ?., ?? gibi kısaltmalar ise bir şey eklemez. Bu kuralları küçük bir deneme class'ında araçla da doğruladım: üç seviye iç içe if 6, on case'li bir switch 1, if/else if/else zinciri 3, a && b || c && d 3 ve s?.Length ?? 0 0 puan aldı.

İkisinin farkını en iyi switch gösterir. On case'li bir switch cyclomatic complexity'yi on artırır ama okuması kolaydır; Cognitive Complexity onu tek bir +1 sayar. Tersine, üç seviye iç içe bir if cyclomatic olarak 3 yol ekler, ama okuyucu üç koşulu aynı anda kafasında tuttuğu için Cognitive Complexity 1 + 2 + 3 = 6 verir. ProcessData'nın iki metrikte bu kadar farklı çıkmasının (cyclomatic 27, cognitive 92) sebebi bu: dallanma sayısı yüksek, ama asıl yük iç içe geçmişlikten geliyor.

SonarAnalyzer'ın (SonarSource'un C# için Roslyn analyzer paketi) S3776 (Cognitive Complexity) kuralını eşiği 0'a çekerek her method için çalıştırdım. Önce ve sonra:

MethodCognitive Complexity
ProcessData (legacy)92
GetQuote (legacy)13
PlaceOrder3
CheckCanOrder5
CompleteOrder, ReserveStock, AwardLoyaltyPoints1'er
CreateOrder, CreateItem, RejectPayment, FindBook ve diğerleri0
OrderPricing'in 8 method'utoplam 10, en yüksek 2
OrderNotifier.BuildConfirmationBody1

Toplam karmaşıklık sıfırlanmadı, olmaz da: indirim kuralları hâlâ orada. Ama 92'lik tek bir yığın, en yükseği 5 olan küçük parçalara dağıldı ve her parçayı ötekileri aklında tutmadan okuyabiliyorsun. Metriği bir hedef olarak değil, "nereye bakmalıyım" sorusunun cevabı olarak kullan; 16 çıkan bir method'u 15'e indirmek için anlamsız bölmek metriği iyileştirir ama kodu iyileştirmez. Analyzer'ları projeye kalıcı eklemek ve eşikleri ekipçe ayarlamak ilerideki Statik Analiz serisinin konusu.

Sık Düşülen Tuzaklar

Aşırı bölmek. Her satırı bir method'a çıkarmak, okuyucuyu bir hikâye yerine bir referans kitabıyla baş başa bırakır. John Ousterhout (Stanford profesörü, Tcl dilinin yaratıcısı), A Philosophy of Software Design'da (2018) bunu "shallow method" diye eleştirir: interface'i neredeyse implementasyonu kadar karmaşık olan, bir şey saklamayan method. Martin ile 2024–2025'teki tartışmalarında Ousterhout'un asıl itirazı da buydu: birbirini anlamadan okunamayan küçük method'lar (onun deyimiyle "entangled") tek bir uzun method'dan daha zor okunur. ToMinorUnits gibi bir method bir kavrama isim verdiği için değerli; AddOneToCount gibi bir method ise yalnız count++'ı uzatır. Bu tartışmaya 13. yazıda ayrıntılı döneceğiz.

Çıkarılan method'lar arasında çok parametre dolaştırmak. Bir method'u böldüğünde parçalar arasında beş altı değer taşımaya başladıysan, büyük ihtimalle eksik bir class ya da record var. PlaceOrderRequest ve OrderTotals bu yüzden doğdu.

Adı bir şey, işi başka bir şey olan küçük method'lar. Method küçüldü diye side effect'i kaybolmaz; CheckStock 11 satırdı ve yine de stok düşürüyordu. Bir method'u çıkarırken adını gövdesine göre koy ve gövde değiştikçe adı da değiştir.

Guard clause'u her yerde exception'a çevirmek. Beklenen iş durumları (geçersiz kupon, kartın reddedilmesi) için exception fırlatmak kontrol akışını görünmez yapar ve pahalıdır. Microsoft'un exception best practices sayfası da yaygın, beklenen durumlar için exception yerine bir return value ya da Try pattern'i öneriyor. Exception'ı programcı hatası için sakla.

Metriği hedefe çevirmek. Cognitive Complexity eşiğini PR'ları (pull request) bloklayan bir kurala bağlarsan, ekip sayıyı düşürmek için kodu böler ve okunabilirlik kazanılmaz.

Ne Zaman Bölme

Bir algoritma bir bütün olarak anlaşılıyorsa (bir parser'ın state machine'i, bir sayısal hesap, bir sıralama) onu parçalara bölmek okuyucuyu parçalar arasında zıplatır. 30 satırlık ama yukarıdan aşağıya düz okunan, tek seviyede ve tek bir kavramı anlatan bir method'u bölmek zorunda değilsin. Performans açısından da çok sıcak döngülerde küçük method çağrıları JIT (Just-In-Time compiler) tarafından genellikle inline edilir; ama virtual ya da interface çağrıları her zaman edilmez ve bu fark ancak ölçünce görünür. Kural: önce oku, sonra böl; bölmeden önce "bu parçanın adı, gövdesinden daha fazlasını söylüyor mu" diye sor.

Küçük Bir Deneme

★☆☆ Legacy IsValid(Order o)'yu CQS'e uygun hâle getir: bir query (IsValid) ve bir command (Approve) olarak ikiye ayır. Ürün sayfası IsValid'i iki kez çağırdığında siparişin durumunun değişmediğini bir programla göster.

★★☆ Legacy CancelOrder(int id, bool refund, bool notify)'ın dört kullanım şeklini bul ve flag'leri kaldır: hangisi ayrı method olmalı, hangisi bir request record'una taşınmalı? Yeni hâlinde try/catch bloğunun ve return false'ların ne olduğuna bak; neyi değiştirmeden bıraktığını not et.

★★★ SonarAnalyzer.CSharp paketini bir projeye ekle, S3776'nın eşiğini SonarLint.xml ile 0'a çek ve legacy ShipOrder'ın Cognitive Complexity'sini ölç (bende 10 çıktı). Sonra guard clause'larla yeniden yaz ve tekrar ölç. Hangi değişiklik kaç puan kazandırdı? White paper'daki kurallarla elle hesaplayıp aracın sonucuyla karşılaştır.

Bir sonraki yazıda bu dosyanın en sessiz ama en çok yalan söyleyen kısmına geçiyoruz: comment'lere. // eski indirim hesabı - kullanılmıyor ama silme! satırına, changelog comment'lerine ve commented-out ProcessDataV2'ye bakacak; hangi comment'in kodun yerini tutamayacağını ve XML (Extensible Markup Language) doc comment'lerin derleyiciyle nasıl doğrulandığını göreceğiz.