Single Responsibility Principle: "Tek İş" Değil, Tek Actor
SRP "bir class tek şey yapar" demek değildir; Martin'in tanımıyla bir module tek bir actor'a, yani aynı değişikliği isteyen tek bir kişi ya da gruba karşı sorumlu olmalıdır. Muhasebenin bir değişikliğinin İK raporunu bozduğu Employee örneği, veriyi paylaşıp davranışı ayırmak, Facade, .NET'teki Stream/StreamWriter/Encoding ayrımı ve fragmentation tuzağı.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un mağaza çalışanları için yazılmış bir Employee class'ı var. Muhasebe bir talep açtı (#412): eğitim saatleri artık saatlik ücretle ödenmeyecek, onun yerine haftalık sabit bir eğitim ödeneği verilecek. Geliştirici maaş hesabının kullandığı RegularHours method'unu güncelledi, Grace'in maaşını kontrol etti, rakam muhasebenin beklediğiyle aynı çıktı. Grace bu hafta 36 saat normal mesai ve 8 saat eğitim yaptı. Değişiklikten önce İK (insan kaynakları) raporu "44 h worked, 40 regular, 4 overtime" yazıyordu. Şimdi ne yazar?
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Employee grace = new(7, "Grace", hourlyRate: 20m,
[
new TimeEntry(new DateOnly(2026, 11, 2), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 3), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 4), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 5), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 6), 8, WorkKind.Training),
new TimeEntry(new DateOnly(2026, 11, 7), 4, WorkKind.Regular)
]);
Console.WriteLine($"Pay: {grace.CalculatePay()} EUR");
Console.WriteLine($"Report: {grace.ReportHours()}");
public enum WorkKind { Regular, Training }
public record TimeEntry(DateOnly Date, decimal Hours, WorkKind Kind);
public sealed class Employee(int id, string name, decimal hourlyRate, IReadOnlyList<TimeEntry> week)
{
private const decimal TrainingAllowance = 100m;
// Muhasebe (CFO) ister
public decimal CalculatePay()
{
bool hadTraining = week.Any(e => e.Kind == WorkKind.Training);
return RegularHours() * hourlyRate + (hadTraining ? TrainingAllowance : 0m);
}
// İK ve operasyon (COO) ister
public string ReportHours()
{
decimal total = week.Sum(e => e.Hours);
decimal regular = RegularHours();
return $"{name}: {total} h worked, {regular} regular, {total - regular} overtime";
}
// Veritabanı ekibi (CTO) ister
public void Save() => Console.WriteLine($"UPDATE Employees SET ... WHERE Id = {id}");
// Ortak yardımcı: hem maaş hem rapor kullanıyor
private decimal RegularHours()
{
// Muhasebe talebi #412: eğitim saatleri artık saatlik ödenmiyor
decimal worked = week.Where(e => e.Kind != WorkKind.Training).Sum(e => e.Hours);
return Math.Min(worked, 40);
}
}
Muhasebenin Değişikliği İK'nın Raporunu Bozdu
Çıktı:
Pay: 820 EUR
Report: Grace: 44 h worked, 36 regular, 8 overtime
Maaş doğru: muhasebe 36 saat × 20 EUR + 100 EUR ödenek istiyordu. Ama İK raporu artık Grace'in 8 saat fazla mesai yaptığını söylüyor; gerçekte 4 saat. Bu rapor yasal çalışma süresi takibine gidiyor ve İK'nın kuralına göre eğitim de çalışma saatidir. Kimse İK'ya "raporunuzu değiştiriyoruz" demedi, çünkü kimse raporu değiştirmedi; yalnızca iki method'un paylaştığı küçük bir yardımcı değişti.
Bu senaryo Robert C. Martin'in ("Uncle Bob" olarak da bilinen, Clean Code kitabının da yazarı) Clean Architecture (2017) kitabında SRP'yi (Single Responsibility Principle) anlatırken kullandığı örneğin Bookstore'a uyarlanmış hâli. Martin'in teşhisi şu: CalculatePay ve ReportHours aynı class'ta, aynı yardımcıyı paylaşıyor; ama farklı insanlara hizmet ediyorlar. Biri CFO'nun (Chief Financial Officer, finans direktörü) ekibine, diğeri COO'nun (Chief Operating Officer, operasyon direktörü) ekibine, Save ise CTO'nun (Chief Technology Officer, teknoloji direktörü) ekibine. Sorun class'ın "çok şey yapması" değil, üç farklı değişim kaynağını tek bir yere bağlaması.
Yaygın Yanlış: "Bir Class Tek Bir Şey Yapar"
SRP en çok yanlış anlaşılan SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) harfi; çünkü adı yanlış anlaşılmaya çok müsait. "Single responsibility" duyan çoğu kişi "her class tek bir şey yapmalı" diye anlıyor. Bu, aslında fonksiyonlar için söylenen ayrı bir kural: bir fonksiyon tek bir iş yapmalı ve onu iyi yapmalı. İlerideki Clean Code serisinde bu kuralı method seviyesinde ayrıca konuşacağız. SRP ise başka bir şey söylüyor.
Martin'in ilk formülasyonu "bir module'ün değişmek için tek bir nedeni olmalı" şeklindeydi. Bu da yoruma açıktı: bug fix bir neden mi, refactoring bir neden mi? Martin 2014'te yazdığı "The Single Responsibility Principle" yazısında bunu netleştirdi: değişim nedenleri insanlardır. Kendi cümlesiyle: "This principle is about people." Clean Architecture'da (2017) son hâlini verdi: bir module tek bir actor'a karşı sorumlu olmalı. Actor, aynı değişikliği isteyen kişi ya da kişi grubu; bir rol, bir ekip, bir iş birimi.
Bu tanım SRP'yi teknik bir kuraldan organizasyonel bir gözleme çeviriyor. Bir class'ın method'larını sayarak SRP'ye uyup uymadığını anlayamazsın; o method'ları kimin değiştirmek isteyeceğini sorman gerekir. Yirmi method'lu bir class tek bir actor'a hizmet ediyorsa SRP açısından sorun yok; iki method'lu bir class iki ayrı ekibin isteğiyle değişiyorsa sorun var.
Fikir yeni değil. Martin aynı yazıda kökü David Parnas'ın (information hiding fikrinin sahibi) 1972'deki module ayrıştırma makalesine, Edsger Dijkstra'nın (structured programming'in öncüsü) separation of concerns'üne ve Larry Constantine'in (coupling ve cohesion kavramlarını ortaya koyan Structured Design'ın yazarlarından) cohesion'ına bağlar. Önceki yazıda gördüğümüz functional cohesion ile SRP aynı soruyu farklı açılardan sorar: bu parçaları bir arada tutan şey ne? SRP'nin cevabı: aynı nedenle değişmeleri.
Actor'ı Bulmak: Değişikliği Kim İster?
Pratikte actor'ı bulmak için birkaç soru işe yarar. Bu method'un davranışını değiştirmek için kimden talep gelir? Bu talebi kim onaylar? Bu method yanlış çalışırsa kim fark eder ve kim şikâyet eder? Employee için cevaplar net: maaş yanlışsa muhasebe, saat raporu yanlışsa İK, tablo yapısı değişecekse veritabanı ekibi.
Kod tabanında da iz bırakan iki belirti var. Birincisi, bu yazının başındaki gibi kazara bozulmalar: bir ekibin talebi, başka bir ekibin özelliğini bozuyor. İkincisi merge conflict'leri: muhasebeden ve İK'dan gelen iki ayrı talep aynı hafta aynı dosyada değişiklik yapıyorsa, iki geliştirici aynı class üzerinde çakışır. Martin bu ikisini SRP ihlalinin iki semptomu olarak sayar. Git geçmişinde aynı dosyaya farklı ekiplerin ticket numaralarıyla gelen commit'ler görüyorsan, o dosyada birden fazla actor var demektir.
Burada dikkat edilecek bir nokta var: aynı kişi farklı şapkalarla da farklı actor olabilir. Küçük bir şirkette maaş kuralını da raporu da aynı müdür isteyebilir. Ama bu iki kural farklı mevzuattan, farklı süreçlerden geliyorsa ve farklı zamanlarda değişiyorsa, onları ayrı actor'lar gibi düşünmek yine doğru olur. Soru kişiyi değil, değişimin kaynağını bulmak.
Ayrıştırma: Veriyi Paylaş, Davranışı Ayır
Martin'in önerdiği ilk çözüm, veriyi davranıştan ayırmak: üç tarafın da okuduğu davranışsız bir veri yapısı ve her actor için ayrı bir class. Employees.cs:
namespace Bookstore.Staff;
public enum WorkKind { Regular, Training }
public record TimeEntry(DateOnly Date, decimal Hours, WorkKind Kind);
// Davranışı olmayan, üç tarafın da okuduğu veri
public sealed record EmployeeData(int Id, string Name, decimal HourlyRate, IReadOnlyList<TimeEntry> Week);
// Muhasebe (CFO): maaş kuralları burada değişir
public sealed class PayCalculator
{
private const decimal TrainingAllowance = 100m;
public decimal Calculate(EmployeeData employee)
{
bool hadTraining = employee.Week.Any(e => e.Kind == WorkKind.Training);
return PaidRegularHours(employee) * employee.HourlyRate + (hadTraining ? TrainingAllowance : 0m);
}
private static decimal PaidRegularHours(EmployeeData employee) =>
Math.Min(employee.Week.Where(e => e.Kind != WorkKind.Training).Sum(e => e.Hours), 40);
}
// İK ve operasyon (COO): çalışma saati raporu burada değişir
public sealed class HourReporter
{
private const decimal WeeklyLimit = 40;
public string Report(EmployeeData employee)
{
decimal total = employee.Week.Sum(e => e.Hours);
decimal regular = Math.Min(total, WeeklyLimit);
return $"{employee.Name}: {total} h worked, {regular} regular, {total - regular} overtime";
}
}
// Veritabanı ekibi (CTO): kayıt biçimi burada değişir
public sealed class EmployeeRepository
{
public void Save(EmployeeData employee) =>
Console.WriteLine($"UPDATE Employees SET ... WHERE Id = {employee.Id}");
}
Program.cs:
using System.Globalization;
using Bookstore.Staff;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
EmployeeData grace = new(7, "Grace", HourlyRate: 20m,
[
new TimeEntry(new DateOnly(2026, 11, 2), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 3), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 4), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 5), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 6), 8, WorkKind.Training),
new TimeEntry(new DateOnly(2026, 11, 7), 4, WorkKind.Regular)
]);
Console.WriteLine($"Pay: {new PayCalculator().Calculate(grace)} EUR");
Console.WriteLine($"Report: {new HourReporter().Report(grace)}");
new EmployeeRepository().Save(grace);
Pay: 820 EUR
Report: Grace: 44 h worked, 40 regular, 4 overtime
UPDATE Employees SET ... WHERE Id = 7
Maaş hâlâ 820 EUR, rapor yine doğru: 40 normal, 4 fazla mesai. Dikkat edersen "normal saat" hesabı artık iki yerde ve iki yerde farklı. Bu bir eksiklik değil, ayrıştırmanın amacı tam olarak bu. Muhasebenin "ödenen normal saati" ile İK'nın "çalışılan normal saati" aynı kod gibi başlamıştı ama aynı bilgi değildi; Martin bu duruma accidental duplication diyor. İlk kodda tek bir method'da birleştirilmeleri, birinin değişikliğinin diğerine sızmasına yol açtı. DRY'ın (Don't Repeat Yourself) kod tekrarını değil, bilginin tekrarını hedeflediğini ilerideki DRY, KISS (Keep It Simple, Stupid) ve YAGNI (You Aren't Gonna Need It) yazısında ayrıntılı göreceğiz.
Bu tasarımın bir bedeli var: artık üç class'ı oluşturmak ve doğru olanı bulmak gerekiyor. Bunu kolaylaştırmak için Martin'in ikinci önerisi Facade.
Facade ile Tek Kapıdan Girmek
Facade, kendisi iş yapmayan, yalnızca isteği doğru class'a yönlendiren ince bir giriş noktası:
namespace Bookstore.Staff;
// Tek giriş noktası: iş yapmaz, yalnızca doğru class'a yönlendirir
public sealed class EmployeeFacade(EmployeeData employee)
{
private readonly PayCalculator _pay = new();
private readonly HourReporter _hours = new();
private readonly EmployeeRepository _repository = new();
public decimal CalculatePay() => _pay.Calculate(employee);
public string ReportHours() => _hours.Report(employee);
public void Save() => _repository.Save(employee);
}
using System.Globalization;
using Bookstore.Staff;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
EmployeeFacade grace = new(new EmployeeData(7, "Grace", HourlyRate: 20m,
[
new TimeEntry(new DateOnly(2026, 11, 2), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 6), 8, WorkKind.Training)
]));
Console.WriteLine($"Pay: {grace.CalculatePay()} EUR");
Console.WriteLine($"Report: {grace.ReportHours()}");
Pay: 260 EUR
Report: Grace: 16 h worked, 16 regular, 0 overtime
Client kod eskisi gibi tek bir object üzerinden çalışıyor ama artık mantık ayrı class'larda. Facade'ın kendisi de değişebilir, ama yalnızca yeni bir operasyon eklendiğinde; bir actor'ın kuralı değiştiğinde değişmesi gerekmez. Martin bir varyasyondan daha bahseder: en önemli davranışı (örneğin maaş hesabını) orijinal class'ta bırakıp diğerlerini dışarı almak. Hangisini seçeceğin, client'ların çoğunun hangi davranışı kullandığına bağlı.
Gerçek bir ASP.NET Core uygulamasında bu üç class'ı Facade'ın içinde new ile değil, constructor'dan alırdık; bunun nedenlerini ve composition root'u ilerideki Dependency Inversion yazısında konuşacağız. DI (dependency injection) container'ın mekaniği için C# İleri serisindeki "Dependency Injection Temelleri" yazısına bakabilirsin.
.NET'te SRP: Stream, StreamWriter ve Encoding
.NET'in IO (input/output) tasarımı SRP'nin iyi bir örneği. Bir metni dosyaya yazmak üç ayrı karar içerir: byte'lar nereye gidecek, metin byte'a nasıl çevrilecek, metnin biçimi ne olacak. .NET bu üç kararı üç ayrı parçaya dağıtır:
using System.Text;
string line = "978-2253096337;Les Misérables;Victor Hugo";
Encoding[] encodings = [new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), Encoding.Unicode, Encoding.Latin1];
foreach (Encoding encoding in encodings)
{
// Depolama (Stream) ve metin kodlaması (StreamWriter + Encoding) ayrı parçalar
using MemoryStream storage = new();
using (StreamWriter writer = new(storage, encoding, leaveOpen: true))
{
writer.Write(line);
}
Console.WriteLine($"{encoding.WebName,-10} {storage.Length,3} bytes");
}
utf-8 42 bytes
utf-16 84 bytes
iso-8859-1 41 bytes
Aynı satır üç farklı byte uzunluğu üretti. UTF (Unicode Transformation Format) ailesinden UTF-8'de (değişken uzunluklu kodlama) "é" iki byte, Latin-1'de (ISO-8859-1, Batı Avrupa dilleri için tek byte'lık kodlama) bir byte; UTF-16 (Unicode'un iki byte'lık birimlerle çalışan kodlaması) bu metindeki her karakter için iki byte artı iki byte'lık BOM (byte order mark) yazıyor. Burada önemli olan, bu farkın tamamen Encoding object'inden gelmesi. MemoryStream metinden habersiz, yalnızca byte saklıyor; onu bir FileStream ile değiştirmek encoding'i etkilemez. StreamWriter ise byte'ların nereye gittiğini bilmeden metni çeviriyor. Her parçanın farklı bir değişim nedeni var: depolama ortamı, karakter kodlaması standardı ve senin uygulamanın satır biçimi.
ASP.NET Core'un logging tasarımı da benzer bir ayrım yapar. ILogger<T> ile neyi loglayacağına uygulamayı yazan geliştirici karar verir; log'un konsola mı, bir dosyaya mı, bir merkezi sisteme mi gideceğine logger provider'lar; hangi seviyenin yazılacağına ise configuration. Uygulama kodu, operasyon ekibinin log hedefini değiştirmesinden etkilenmez.
SRP'nin Seviyeleri: Method, Class, Component
"Aynı nedenle değişenleri bir araya topla, farklı nedenle değişenleri ayır" fikri her seviyede karşımıza çıkar, ama her seviyede farklı bir adla:
| Seviye | Kural | Nerede konuşuyoruz |
|---|---|---|
| Method | Bir fonksiyon tek bir iş yapar, tek bir soyutlama seviyesinde kalır | Clean Code serisi |
| Class | Bir class tek bir actor'a karşı sorumludur (SRP) | Bu yazı |
| Component (project) | Aynı nedenle ve aynı zamanda değişen class'lar aynı project'te durur (Common Closure Principle) | Component prensipleri yazısı |
| Mimari | Farklı hızda değişen katmanlar arasına sınır çekilir | Clean, Onion ve Hexagonal Architecture serisi |
Bu tablo bir şeyi netleştiriyor: SRP'yi method seviyesine indirip "her class tek method" diye uygulamak, prensibi yanlış seviyede kullanmak demek. Method seviyesindeki kural okunabilirlik içindir; class seviyesindeki kural değişimin kimden geldiğiyle ilgilidir.
Sık Düşülen Tuzaklar
Fragmentation: her method'a bir class. SRP'yi "bir class bir iş" diye okuyanlar şöyle bir kod üretir:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
OrderLine[] lines = [new("Dune", 2, 12.99m), new("Emma", 1, 7.50m)];
OrderTotalCalculator total = new(new OrderSubtotalCalculator(), new OrderVatCalculator(), new OrderRoundingPolicy());
Console.WriteLine(total.Calculate(lines));
public record OrderLine(string Title, int Quantity, decimal UnitPrice);
// Dört class, tek actor: muhasebe her değişiklikte dördünü birden açıyor
public sealed class OrderSubtotalCalculator
{
public decimal Calculate(IEnumerable<OrderLine> lines) => lines.Sum(l => l.Quantity * l.UnitPrice);
}
public sealed class OrderVatCalculator
{
public decimal Calculate(decimal subtotal) => subtotal * 0.20m;
}
public sealed class OrderRoundingPolicy
{
public decimal Apply(decimal amount) => Math.Round(amount, 2, MidpointRounding.AwayFromZero);
}
public sealed class OrderTotalCalculator(
OrderSubtotalCalculator subtotal, OrderVatCalculator vat, OrderRoundingPolicy rounding)
{
public decimal Calculate(IEnumerable<OrderLine> lines)
{
decimal net = subtotal.Calculate(lines);
return rounding.Apply(net + vat.Calculate(net));
}
}
40.18
Dört class var ama hepsi aynı actor'a, muhasebeye hizmet ediyor. KDV (katma değer vergisi) oranı, yuvarlama kuralı ya da ara toplamın içine kargo ücretinin girmesi gibi her değişiklik muhasebeden gelecek ve çoğu zaman bu dört class'ı birlikte açmayı gerektirecek. Martin Fowler'ın Refactoring (1999) kitabındaki adıyla bu shotgun surgery: tek bir değişiklik için birçok yere dokunmak. Burada tek bir OrderPricing class'ı daha cohesive ve SRP'ye daha uygun. SRP ayırmak kadar bir araya toplamayı da söyler.
Accidental duplication'ı "düzeltmek". Ayrıştırmadan aylar sonra biri PayCalculator ve HourReporter'daki benzer satırları görüp ortak bir WorkHours.Regular() helper'ına çeker; yazının başındaki hata geri gelir. Ayrı tutulan iki benzer kodun yanına neden ayrı olduklarını anlatan kısa bir yorum yazmak bu riski azaltır.
İsimle saklanan çoklu sorumluluk. OrderManager, CustomerService, BookHelper gibi isimler hiçbir sorumluluğu sınırlamaz; içine her şey girebilir. Bir class'ın adını actor'ın diliyle koymaya çalış (PayCalculator, HourReporter); koyamıyorsan, büyük ihtimalle birden fazla actor'a hizmet ediyordur.
SRP'yi data class'larına uygulamak. EmployeeData gibi davranışsız bir record'u "çok fazla property var" diye bölmenin SRP ile ilgisi yok. Veri yapısının şekli, onu kullanan davranışlara göre değişir; asıl soru bu davranışların nerede durduğu.
Ne Zaman Ayırmamalı
Tek bir actor varsa. İç kullanım için yazılmış, tek bir ekibin bakımını yaptığı bir yönetim ekranında maaş, rapor ve kayıt aynı kişinin talebiyle değişiyorsa, onları üç class'a bölmek yalnızca dosya sayısını artırır. Actor'lar ayrıştığında (örneğin rapor İK'ya geçtiğinde) bölmek geç değildir; bu yazıdaki dönüşüm küçük bir refactoring'dir.
Değişim gelmiyorsa. Yıllardır dokunulmamış, testleri olan ve kimsenin yeni talep getirmediği bir class'ı "SRP'ye uymuyor" diye bölmek, riski ödüle değmeyen bir yatırım. Prensip değişimin acısını azaltmak için var; acı yoksa ilacı da bekletebilirsin.
Prototip ve script'lerde. Bir veri taşıma script'i ya da bir haftalık bir deneme, actor analizine harcanan zamanı geri ödemez. Bu kodun bir gün ürünleşeceği belli olduğunda ayrıştırma ilk iş olur.
Küçük Bir Deneme
★☆☆ İlk örnekteki Employee class'ında RegularHours'ı, İK raporunu bozmadan muhasebenin talebini karşılayacak şekilde değiştir; ama class'ı bölmeden. Bu çözümün bir sonraki talep geldiğinde neden yeniden kırılabileceğini bir cümleyle yaz.
★★☆ Fragmentation örneğindeki dört class'ı tek bir OrderPricing class'ında birleştir ve aynı 40.18 sonucunu aldığını doğrula. Ardından "kargo ücreti ara toplama dahil edilsin" talebini uygula. Kaç dosyaya dokundun, eski tasarımda kaç dosyaya dokunacaktın?
★★★ Kendi projende son altı ayda en çok commit alan class'ı seç. Commit mesajlarındaki ticket'ları ekip ya da iş birimine göre grupla. Birden fazla actor çıkarsa, Facade ile ayrıştırmanın taslağını çiz; hangi method hangi class'a gidecek, ortak veri hangi record'da duracak?
Bir sonraki yazıda O harfine geçiyoruz: Open/Closed Principle. Seri örneğimizdeki büyüyen bildirim switch'ini, Bertrand Meyer'in (Eiffel dilinin tasarımcısı) ve Martin'in iki farklı OCP (Open/Closed Principle) tanımını, .NET 8 ile gelen keyed services'i ve değişmeyecek yere extension point koymanın bedelini konuşacağız.