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

Formatting ve Dosya Düzeni: Kodu Gazete Gibi Okunur Kılmak

Mert Özen 7 Kas 2026 11 dk 2 görüntülenme
Formatting ve Dosya Düzeni: Kodu Gazete Gibi Okunur Kılmak

Formatting bir estetik değil iletişim meselesidir ve kuralını insan değil araç uygulamalıdır: süslü parantezsiz bir if'te yanlış girinti, e-posta gönderilmediği hâlde log'a 'gönderildi' yazdırıyor ve bunu derleyici değil SonarAnalyzer ile dotnet format yakalıyor. Yazı newspaper metaphor'ü, dikey düzeni, member sırasını, bir dosyada bir tip kuralını, file-scoped namespace dönüşümünü ve 259 karakterlik LINQ zincirini satırlara bölmeyi 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

Legacy ProcessData'nın sonundaki e-posta bloğunu ayrı bir class'a taşıyan bir arkadaş, onay e-postasının gönderildiğini bir de log'a yazmak istemiş ve bir satır eklemiş:

namespace Bookstore.Ordering;

public sealed class ConfirmationSender(TextWriter log)
{
    public void Send(string email, int orderId, bool sendMail)
    {
        if (sendMail)
            log.WriteLine($"MAIL to {email}: Order #{orderId}");
            log.WriteLine($"Confirmation logged for {email}");
        log.WriteLine("Done");
    }
}

Çağıran kod e-posta göndermek istemiyor:

using Bookstore.Ordering;

var sender = new ConfirmationSender(Console.Out);
sender.Send("ada@example.com", 1000, sendMail: false);

Program ne yazar? if (sendMail) satırının altındaki iki satır aynı hizada; ikisi de koşula bağlı görünüyor.

Girinti Yalan Söyleyebilir

Gerçek çıktı:

Confirmation logged for ada@example.com
Done

E-posta gönderilmedi ama log "onay kaydedildi" diyor. C#'ta süslü parantez kullanılmayan bir if yalnız kendisinden sonraki ilk satırı kapsar; ikinci satır girintisi ne derse desin her zaman çalışır. Derleyici bunu bir problem olarak görmez: build sıfır uyarıyla geçti. Girinti derleyici için anlamsız, okuyucu için ise tek ipucu. Bu yüzden yanlış girinti, yanlış bir comment kadar tehlikelidir.

Bu tür hatayı iki şey yakalar. SonarAnalyzer (SonarSource'un C# için Roslyn analyzer paketi) açıkken build şunu söyledi:

ConfirmationSender.cs(9,13): warning S2681: This line will not be executed conditionally; only the first line of this 2-line block will be. The rest will execute unconditionally.

İkincisi dotnet format. Girintiyi kodun gerçek yapısına göre yeniden hesaplar ve yalanı görünür yapar:

$ dotnet format whitespace --verify-no-changes
ConfirmationSender.cs(9,9): error WHITESPACE: Fix whitespace formatting. Delete 4 characters. [P05a.csproj]
$ dotnet format whitespace
$ diff before.cs ConfirmationSender.cs
9c9
<             log.WriteLine($"Confirmation logged for {email}");
---
>         log.WriteLine($"Confirmation logged for {email}");

Formatter satırı geri çekince, ikinci satırın hiçbir zaman if'e ait olmadığı ortaya çıktı. Asıl düzeltme ise ayrı bir karar: log satırı if'in içine mi girecek, dışında mı kalacak? Bunu formatter bilemez. .NET runtime ekibinin coding style dokümanı bu yüzden süslü parantezi, tüm dallar tek satır değilse zorunlu tutar; tek satırlık if'lerde bile parantez kullanan ekipler bu hatayı hiç yaşamaz.

Formatting'in Amacı: İletişim

Robert C. Martin (Clean Code kitabının yazarı) formatting'i bir estetik meselesi olarak değil, iletişim olarak görür. 2008 baskısındaki gerekçesi şu: bugün yazdığın kodun işlevi bir sonraki sürümde büyük ihtimalle değişir, ama okunabilirliği ondan sonraki her değişikliği etkilemeye devam eder. İkinci baskının Formatting bölümü de aynı "The Purpose of Formatting" başlığıyla açılıyor. Bölümün diğer başlıkları dikey ve yatay formatting, ekip kuralları ve Martin'in kendi kuralları.

Martin'in en bilinen benzetmesi newspaper metaphor: iyi bir kaynak dosya bir gazete makalesi gibi okunur. Başlık (class adı) sana doğru yerde olup olmadığını söyler. İlk paragraf (public method'lar) hikâyenin özetini verir. Aşağı indikçe ayrıntı artar ve en altta en düşük seviyeli yardımcılar durur. Okuyucu ihtiyacı olan derinliğe kadar okur ve bırakır. İkinci baskıda bu benzetme, step-down rule ile birlikte "Be Polite" bölümüne taşınmış; başlık bile ne kastedildiğini söylüyor: dosyanı okuyacak kişiye karşı nazik ol.

Fonksiyonlar yazısında yazdığımız OrderPlacementService bu yapıda: en üstte tek public method PlaceOrder, altında onun çağırdığı CheckCanOrder, CreateOrder, CompleteOrder, en altta FindBook, ToMinorUnits gibi bir satırlık yardımcılar. Legacy OrderService ise tersini yapıyor: dosya 2017'deki yazım sırasını ve sonradan eklenenleri koruyor; UpdateStatus, ImportBooks ve ölü OldCalcDiscount dosyanın ortasına, SendMail ve cache method'ları en sona düşmüş.

Dikey Düzen: Boşluk, Yoğunluk ve Yakınlık

Martin dikey düzeni üç kavramla anlatır. Vertical openness: farklı düşünceler arasına boş satır koy. Bir boş satır okuyucuya "burada yeni bir adım başlıyor" der. Legacy ProcessData'nın 136 satırında tek bir boş satır yok; fiyat hesabı, indirim, ödeme ve puan birbirine yapışık. // indirim, // kargo, // ödeme comment'leri tam da bu boşlukların yerini tutmaya çalışıyor.

Vertical density: birbiriyle yakından ilgili satırlar arasına boşluk koyma. Bir object'i kuran beş atama bir paragraftır; aralarına boş satır girerse okuyucu onları ayrı işler sanır.

Vertical distance: birbirini kullanan şeyler birbirine yakın dursun. Bir local değişken ilk kullanıldığı yerin hemen üstünde tanımlanmalı, bir method onu çağıran method'un hemen altında durmalı. Legacy'de GetQuote, kopyası olduğu ProcessData'nın hemen altında ama ikisinin kullandığı KargoUcretiHesapla ondan sonra geliyor; CheckStock'u kullanan bir kod yok ama IsValid'in yanında duruyor. Dosyada bir method'u bulmak için yukarı aşağı kaydırdığın her sefer, bir vertical distance bedeli.

Member Sırası ve Bir Dosyada Bir Tip

.NET'te member sırası için resmî tek bir kural yok, ama yaygın bir uzlaşma var. Runtime ekibinin coding style dokümanı field'ların tipin en üstünde tanımlanmasını söyler. StyleCop (Roslyn tabanlı, açık kaynak bir stil analyzer paketi) ise daha ayrıntılı bir sıra dayatır: önce field'lar, sonra constructor'lar, property'ler ve method'lar; her grubun içinde önce public, sonra private; static'ler instance member'lardan önce. Pratikte iyi işleyen sıra şu:

  • Sabitler ve field'lar.
  • Constructor'lar.
  • Public property'ler ve method'lar, en önemli olan en üstte.
  • Private yardımcılar, onları çağıran public method'un sırasıyla (step-down).

StyleCop.Analyzers paketini legacy dosyaya ekleyince build 190 uyarı verdi. Formatting'le ilgili olanlardan birkaçı:

OrderService.cs(473,22): warning SA1201: A interface should not follow a class
OrderService.cs(537,25): warning SA1204: Static members should appear before non-static members
OrderService.cs(19,21): warning SA1202: 'public' members should come before 'private' members
OrderService.cs(478,18): warning SA1402: File may only contain a single type
OrderService.cs(573,11): warning SA1403: File may only contain a single namespace

SA1402 (File may only contain a single type) dosyada on kez tekrarlandı: OrderService.cs on iki tip barındırıyor ve Book'u arayan biri onu OrderService.cs'te bulmayı tahmin edemez. "Bir dosyada bir tip, dosya adı tip adıyla aynı" kuralı .NET dünyasında neredeyse evrensel ve IDE'lerin (Integrated Development Environment) "Move type to matching file" refactoring'i bunu tek tıkla yapıyor.

Bu 190 uyarının 20'si SA1101 (Prefix local calls with this) kuralından geldi: StyleCop varsayılan olarak instance member'lara this. önekini ister. Runtime ekibinin stili ise tam tersini söyler: this.'ı gerekmedikçe kullanma. İki saygın kaynak çelişiyor ve bu iyi bir ders: formatting kurallarının çoğu doğru-yanlış değil, bir seçim. Önemli olan ekibin bir kez seçip .editorconfig'e yazması ve aracın uygulaması; hangisini seçtiğin ikinci planda.

File-Scoped Namespace

C# 10 ile gelen file-scoped namespace (namespace Bookstore.Shipping;) dosyadaki her satırı bir girinti seviyesi sola çeker. Küçük bir şey gibi görünür, ama 4 boşluk her satırda yatay alan demek ve iç içe if blokları olan bir method'da bu fark hissedilir. Microsoft'un C# coding conventions sayfası da çoğu dosya tek namespace tanımladığı için file-scoped namespace kullanılmasını önerir. Dönüşümü elle yapmana gerek yok; .editorconfig'e csharp_style_namespace_declarations = file_scoped:warning yazıp dotnet format style çalıştırınca araç kendisi yapıyor. Önce:

namespace Bookstore.Shipping
{
    public sealed record ShippingLabel(string Name, string Street, string Zip, string City)
    {
        public override string ToString() => $"{Name}\n{Street}\n{Zip} {City}";
    }
}
$ dotnet format style --diagnostics IDE0161 --severity warn

Sonra:

namespace Bookstore.Shipping;

public sealed record ShippingLabel(string Name, string Street, string Zip, string City)
{
    public override string ToString() => $"{Name}\n{Street}\n{Zip} {City}";
}

Legacy dosyada bu dönüşüm çalışmaz, çünkü dosyada iki namespace var (Bookstore.Legacy ve Vendor.Payments) ve file-scoped namespace dosya başına bir tane olabilir. Formatting kuralları bazen önce bir yapısal düzeltme ister: önce tipleri ayrı dosyalara ayır, sonra namespace'i dönüştür. Bir not daha: IDE0161 gibi IDE kuralları varsayılan olarak dotnet build'de çalışmaz; build'de görmek için EnforceCodeStyleInBuild property'sini açman gerekir.

Yatay Düzen: Satır Uzunluğu ve LINQ Zincirleri

Legacy dosyada 120 karakteri aşan satırlar:

$ awk 'length > 120 { print FILENAME ":" NR ": " length }' OrderService.cs
OrderService.cs:140: 135
OrderService.cs:232: 132
OrderService.cs:298: 196
OrderService.cs:324: 160
OrderService.cs:345: 259
OrderService.cs:355: 144
OrderService.cs:546: 144
OrderService.cs:547: 141
OrderService.cs:548: 152
OrderService.cs:549: 166
OrderService.cs:550: 261
OrderService.cs:551: 264
OrderService.cs:552: 267
OrderService.cs:598: 126

On dört satır. Martin 2008 baskısında kendi sınırını 120 karakter olarak verir; Microsoft'un dokümantasyon örnekleri ise mobilde okunabilsin diye 65 karakterle sınırlanıyor. Tek bir doğru sayı yok, ama 259 karakter her ölçüye göre fazla: bir code review aracında yatay kaydırma, bir diff'te satırın tamamının "değişti" görünmesi demek.

345. satır Report method'unun içindeki tek satırlık LINQ (Language Integrated Query) zinciri:

public string Report(DateTime from, DateTime to, bool csv)
{
    var data = Db.Orders.Where(x => x.Date >= from && x.Date <= to && x.Status != 4 && x.Status != 9).GroupBy(x => x.Customer.Name).Select(g => new { Name = g.Key, Count = g.Count(), Sum = g.Sum(x => x.Total) }).OrderByDescending(x => x.Sum).ToList();
    StringBuilder sb = new StringBuilder();
    if (csv)
    {
        sb.AppendLine("Customer;Orders;Total");
        foreach (var r in data) sb.AppendLine(r.Name + ";" + r.Count + ";" + r.Sum);
    }
    else
    {
        sb.AppendLine("SALES REPORT " + from.ToShortDateString() + " - " + to.ToShortDateString());
        foreach (var r in data) sb.AppendLine(r.Name.PadRight(20) + r.Count.ToString().PadLeft(5) + r.Sum.ToString("0.00").PadLeft(12));
    }
    return sb.ToString();
}

Aynı sorguyu, hiçbir ismi ve davranışı değiştirmeden, yalnız satırlara bölerek yazdım; her operatör kendi satırında ve nokta satırın başında:

using System.Text;
using Bookstore.Legacy;

namespace Bookstore.Reporting;

public static class SalesReport
{
    public static string RenderCsv(IEnumerable<Order> orders, DateTime from, DateTime to)
    {
        var data = orders
            .Where(x => x.Date >= from && x.Date <= to && x.Status != 4 && x.Status != 9)
            .GroupBy(x => x.Customer.Name)
            .Select(g => new { Name = g.Key, Count = g.Count(), Sum = g.Sum(x => x.Total) })
            .OrderByDescending(x => x.Sum)
            .ToList();

        var sb = new StringBuilder();
        sb.AppendLine("Customer;Orders;Total");
        foreach (var r in data)
        {
            sb.AppendLine(r.Name + ";" + r.Count + ";" + r.Sum);
        }

        return sb.ToString();
    }
}
Customer;Orders;Total
Ada Lovelace;2;674.39
Alan Turing;1;607.34
Same output: True

Program aynı siparişleri legacy Report ve yeni RenderCsv ile raporlayıp iki çıktıyı karşılaştırdı: birebir aynı. Fark yalnız okumada. Zincirin her adımı artık bir satır: filtrele, grupla, projekte et, sırala, materialize et. Bir code review'da Status != 9 koşulunu değiştiren bir diff yalnız o satırı gösterir, 259 karakterlik satırın tamamını değil. Noktayı satır başına koymak (.Where, .GroupBy) zincirin devam ettiğini satırın en solunda gösterir; Microsoft'un stil rehberi de satırı ikili operatörlerden önce kırmayı önerir; zincirdeki nokta için de aynı mantık geçerli.

dotnet format ile Otomatikleştirmek

Formatting kuralını insanın değil aracın uygulaması gerekir. dotnet format SDK (Software Development Kit) ile birlikte gelir ve üç alt komutu var: whitespace (girinti, boşluk, satır sonu), style (.editorconfig'teki IDE kuralları, örneğin file-scoped namespace ya da var kullanımı) ve analyzers (üçüncü taraf analyzer'ların code fix'leri). --verify-no-changes ile çalıştırıldığında dosyaya dokunmaz, yalnız rapor verir ve bir değişiklik gerekiyorsa sıfırdan farklı bir çıkış kodu döner (yukarıdaki örnekte 2); bu yüzden CI (Continuous Integration) pipeline'ında bir adım olarak kullanılabilir. .editorconfig'i tasarlamak, analyzer severity'lerini ayarlamak ve bunları ekipçe yönetmek ilerideki Coding Conventions ve Ekip Standartları serisinin konusu; buradaki mesaj yalnız şu: formatting tartışması bir kez yapılır, sonra bir dosyaya yazılır ve araç uygular.

Sık Düşülen Tuzaklar

Formatting tartışmasına ekip zamanı harcamak. Süslü parantezin yeri ya da var kullanımı üzerine yarım saatlik bir code review tartışması, bir .editorconfig satırıyla kapanabilecek bir konu. Martin'in ikinci baskıdaki "Team Rules" başlığı da bunu söyler: ekip bir kez karar verir ve herkes, kendi tercihi farklı olsa bile, ona uyar.

Bütün kod tabanını tek commit'te yeniden formatlamak. Böyle bir commit git blame'i bozar: her satırın son değiştireni "format commit'i" olur ve gerçek tarihçe gömülür. Ya dosyaları dokundukça formatla ya da toplu formatı ayrı bir commit'te yap ve o commit'i git blame --ignore-revs-file ile kullanılan bir .git-blame-ignore-revs dosyasına ekle; GitHub da bu dosyayı tanıyor.

Formatting ile davranış değişikliğini aynı PR'da (pull request) göndermek. Girintisi değişen 300 satırın arasında değişen bir koşul kaybolur. Önce format, ayrı commit; sonra değişiklik.

Herkesin kendi IDE ayarıyla çalışması. Biri tab, biri dört boşluk kullanınca her commit'te değişmeyen satırlar da diff'e girer. Repository'nin kökündeki .editorconfig bu farkı kaldırır.

Kolonları elle hizalamak. Atamaların = işaretlerini alt alta getirmek için boşluk eklemek ilk bakışta düzenli görünür, ama gözü sağdaki değere değil hizaya çeker ve en uzun isim değiştiğinde bütün blok diff'e girer. Martin de 2008 baskısında bu tür yatay hizalamadan vazgeçtiğini anlatır.

Küçük Bir Deneme

★☆☆ Yukarıdaki ConfirmationSender'ı if için süslü parantez kullanarak düzelt ve log satırının içeride mi dışarıda mı olması gerektiğine karar ver. Sonra .editorconfig'e csharp_prefer_braces = true:warning ekleyip dotnet format style çalıştır; araç ne yapıyor?

★★☆ Legacy OrderService.cs'teki on iki tipi ayrı dosyalara böl, her dosyayı file-scoped namespace'e çevir ve OrderService class'ının member'larını yukarıdaki sıraya göre diz. StyleCop.Analyzers ile kaç uyarı kaldı? Hangilerini bilinçli olarak kapatırdın?

★★★ Legacy dosyayı bir Git repository'sine koy, önce tek commit'te dotnet format ile tümünü formatla, sonra ProcessData'da küçük bir değişiklik yap. git blame çıktısına bak; ardından format commit'ini .git-blame-ignore-revs'e ekleyip git blame --ignore-revs-file .git-blame-ignore-revs ile tekrar çalıştır. Hangi satırların sahibi değişti?

Bir sonraki yazıda dosyanın görünüşünden içindeki veriye geçiyoruz: public field'larla dolu Order, string olarak tutulan kitap formatı ve müşteri tipi, o.Customer.Address.City.Name gibi zincirler. Object ile data structure arasındaki farkı, DTO (Data Transfer Object) için record'u ve primitive obsession'ı konuşacağız.