SOLID prensipleri nedir? 5 ilke, C# örnekleriyle

Biri marka mavisi olan, birbirine yaslanmış beş modül bloğu ve yanında kod kartı bulunan illüstrasyon

Kalite ekibi, vardiya raporundaki OEE değerinin tam sayı olarak görünmesini istiyor. OEE (toplam ekipman etkinliği), kullanılabilirlik, performans ve kalite oranlarının çarpımıdır. Bu uygulamada da raporu üreten sınıfın içindeki bir metotla hesaplanıyor. Geliştirici yuvarlamayı o metoda ekliyor ve rapor istendiği gibi çıkıyor. Birkaç gün sonra üretim planlama, haftalık kapasite hesabının neden kaydığını soruyor. Meğer aynı metodu onların hesabı da kullanıyormuş ve artık yuvarlanmış değerlerle çalışıyormuş.

SOLID prensipleri, bu tür sürprizleri azaltmak için kullanılan beş nesne yönelimli tasarım ilkesidir. Beşi de aynı soruya bakar: Bir gereksinim değiştiğinde kodun kaç yerine dokunmak gerekiyor, o dokunuş başka neyi bozabilir? Aşağıda her ilke kısa bir C# örneğiyle, önce sorunlu sonra düzeltilmiş hâliyle anlatılıyor. Son bölümlerde ilkelerin ne zaman gereksiz karmaşıklığa dönüştüğüne ve otomasyon yazılımındaki karşılıklarına bakılıyor.

SOLID prensipleri kısaca: beş harf, beş ilke

SOLID, beş ilkenin İngilizce adlarının baş harflerinden oluşur:

  • S: Single Responsibility Principle (SRP), tek sorumluluk ilkesi
  • O: Open/Closed Principle (OCP), açık/kapalı ilkesi
  • L: Liskov Substitution Principle (LSP), Liskov yerine geçme ilkesi
  • I: Interface Segregation Principle (ISP), arayüz ayrımı ilkesi
  • D: Dependency Inversion Principle (DIP), bağımlılığın tersine çevrilmesi ilkesi
SOLID prensiplerinin beş harfi ve her ilkenin tek satırlık özeti

İlkeleri tek bir çerçevede toplayan kişi Robert C. Martin’dir. Martin bu ilkeleri 1990’ların ikinci yarısında yazdığı makalelerde işledi. Beşi birlikte, 2003 tarihli Agile Software Development, Principles, Patterns, and Practices kitabında yer alır. İki ilke Martin’den eskidir. Açık/kapalı ilkesi Bertrand Meyer’in 1988 tarihli Object-Oriented Software Construction kitabına, yerine geçme ilkesi ise Barbara Liskov’un 1987’deki Data Abstraction and Hierarchy konuşmasına dayanır. SOLID kısaltmasını ise 2004 civarında Michael Feathers önerdi.

İlkelerin ortak hedefi, bir değişikliğin etkisini sınırlamaktır. Martin, 2000 tarihli Design Principles and Design Patterns makalesinde bozulmaya başlayan bir tasarımın dört belirtisini sayar:

  • katılık (rigidity): küçük bir değişikliğin zincirleme değişiklikler istemesi
  • kırılganlık (fragility): değişikliğin ilgisiz yerleri bozması
  • taşınamazlık (immobility): kodun başka bir yerde yeniden kullanılamaması
  • ağdalılık (viscosity): doğru çözümün yamadan daha zahmetli olması

Girişteki OEE hikâyesi, kırılganlığın küçük ama tipik bir örneği.

Tek sorumluluk (SRP): tek iş değil, tek aktör

SRP en çok “bir sınıfın değişmek için tek bir nedeni olmalı” cümlesiyle bilinir. Bu cümle çoğu zaman “her sınıf tek bir iş yapsın” diye okunur ve çalışan sınıfların onlarca küçük parçaya bölünmesine yol açar. Martin, 2014’te tek sorumluluk ilkesini yeniden açıkladığı yazısında ilkenin insanlarla ilgili olduğunu vurgular. Bir modüle gelen değişiklik talepleri tek bir kişiden ya da tek bir iş fonksiyonunu temsil eden, birbirine sıkı bağlı bir gruptan gelmelidir. 2018 tarihli Clean Architecture kitabındaki son hâli daha net: Bir modül bir ve yalnız bir aktöre karşı sorumlu olmalıdır.

Girişteki rapor sınıfı üç aktöre birden hizmet ediyordu:

public record Shift(
    string Line,
    double Availability,
    double Performance,
    double Quality);

public class ShiftReport
{
    // Üretim planlama: OEE formülü
    public double Oee(Shift s) =>
        s.Availability * s.Performance * s.Quality;

    // Kalite: rapor biçimi
    public string ToText(Shift s) =>
        $"{s.Line} | OEE: {Oee(s):P1}";

    // Bilgi işlem: kaydın yeri
    public void Save(Shift s) =>
        File.AppendAllText("rapor.txt", ToText(s));
}

OEE formülüne üretim planlama, rapor biçimine kalite, kaydın nereye yazılacağına bilgi işlem karar veriyor. ToText metodu Oee metodunu çağırıyor. Planlama da aynı metodu kullandığı için kalitenin istediği değişiklik, planlamanın sonucunu sessizce etkiledi. Sınıfın ayrılmış hâli şöyle:

// Üretim planlama bu sınıfı değiştirir
public class OeeCalculator
{
    public double Calculate(Shift s) =>
        s.Availability * s.Performance * s.Quality;
}

// Kalite bu sınıfı değiştirir
public class ShiftReportFormatter
{
    public string ToText(Shift s, double oee) =>
        $"{s.Line} | OEE: {oee:P0}";
}

// Bilgi işlem bu sınıfı değiştirir
public class ShiftReportStore
{
    public void Save(string text) =>
        File.AppendAllText("rapor.txt", text);
}

Kalitenin istediği yuvarlama artık yalnızca biçimlendiricide, P0 biçim dizgesiyle yapılıyor. Hesap sınıfının döndürdüğü değer değişmiyor. Kalite ileride gerçekten farklı bir OEE tanımı isterse bu da görünür bir karar hâline geliyor: Ya kendi hesabını ayrı tutar ya da iki ekip ortak bir tanımda anlaşır.

SRP, OeeCalculator sınıfını daha fazla bölmenizi istemez. Kullanılabilirlik, performans ve kalite oranlarını üç ayrı sınıfa taşımak hiçbir aktörün değişim ihtiyacını birbirinden ayırmaz. Yalnızca dosya sayısını artırır.

Açık/kapalı ilkesi (OCP): mevcut sınıfı açmadan genişletmek

Bir alarm bildirim sınıfı e-posta ve SMS ile yola çıkıyor. Birkaç ay sonra bakım ekibi, alarmların ekip mesajlaşma kanalına da düşmesini istiyor. Aşağıdaki yapıda bunun tek yolu, çalışan ve test edilmiş sınıfı açıp yeni bir case eklemek:

public class AlarmNotifier
{
    public void Send(Alarm alarm, string channel)
    {
        switch (channel)
        {
            case "email": /* SMTP */ break;
            case "sms":   /* SMS servisi */ break;
            // Yeni kanal: bu sınıfı aç, case ekle
        }
    }
}

Meyer’in tanımına göre yazılım varlıkları genişletmeye açık, değişikliğe kapalı olmalıdır. Meyer bunu kalıtımla çözmeyi önermişti. Bugün yaygın olan yorum ise Martin’in 1996 tarihli makalesiyle yayıldı: Sabit kalan bir arayüz ve o arayüzü uygulayan sınıflar. Yeni bir davranış gerektiğinde yeni bir sınıf eklenir.

public interface IAlarmChannel
{
    void Send(Alarm alarm);
}

public class EmailChannel : IAlarmChannel
{
    public void Send(Alarm alarm) { /* SMTP */ }
}

// Yeni kanal = yeni sınıf; bu sınıf değişmez
public class AlarmNotifier
{
    private readonly IEnumerable<IAlarmChannel> _channels;

    public AlarmNotifier(
        IEnumerable<IAlarmChannel> channels)
        => _channels = channels;

    public void Notify(Alarm alarm)
    {
        foreach (var c in _channels) c.Send(alarm);
    }
}

Yeni kanal artık yeni bir sınıf demek. AlarmNotifier ve testleri olduğu gibi kalır. Hangi kanalların devrede olacağına uygulama başlarken, kayıt aşamasında karar verilir.

“Kapalı”, koda hiç dokunulmayacağı anlamına gelmez. Hata düzeltmesi yine sınıfın kendisinde yapılır. OCP, önceden görülebilen bir değişim ekseni için anlamlıdır. Bu örnekte o eksen bildirim kanalı. Tek kanal varsa ve ikincisinin geleceğine dair somut bir işaret yoksa switch makul bir başlangıçtır. Soyutlama, ikinci ya da üçüncü kanal gerçekten geldiğinde çıkarılır.

Liskov yerine geçme ilkesi (LSP): kare neden dikdörtgenden türetilmemeli

Matematikte her kare bir dikdörtgendir. Bu yüzden Square sınıfını Rectangle sınıfından türetmek ilk bakışta doğru görünür. Martin’in ifadesiyle LSP şunu ister: Temel sınıf referansı kullanan fonksiyonlar, türetilmiş sınıfın nesnelerini farkında olmadan kullanabilmelidir. Kare–dikdörtgen örneği, sezginin tam olarak nerede yanıldığını gösterir:

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int Area => Width * Height;
}

public class Square : Rectangle
{
    public override int Width
    {
        set { base.Width = value; base.Height = value; }
    }

    public override int Height
    {
        set { base.Width = value; base.Height = value; }
    }
}

// Rectangle sözleşmesine güvenen kod
void Resize(Rectangle r)
{
    r.Width = 5;
    r.Height = 4;
    Debug.Assert(r.Area == 20); // Square ile 16
}

Resize metodu Rectangle sözleşmesine güveniyor: Genişlik ve yükseklik birbirinden bağımsız ayarlanır. Square bu sözü tutamaz. Yükseklik 4 yapıldığında genişliği de 4’e çeker ve alan 20 yerine 16 çıkar. Derleyici hata vermez, çünkü sorun tür sisteminde değil, davranıştadır.

LSP bir sınıflandırma kuralı değil, bir sözleşme kuralıdır. Liskov ve Wing, 1994’te davranışsal alt tür kavramını biçimsel olarak tanımladı. Bu tanımın pratikteki karşılığı üç maddedir:

  • Alt tür, girdiler için üst türden daha katı bir koşul istemez.
  • Alt tür, üst türün garanti ettiği sonucu zayıflatmaz.
  • Üst türde her zaman doğru olan kurallar alt türde de doğru kalır.

Düzeltmenin yolu, iki türe tutamayacakları bir sözü vermemektir:

public interface IShape
{
    int Area { get; }
}

public record Rectangle(int Width, int Height) : IShape
{
    public int Area => Width * Height;
}

public record Square(int Side) : IShape
{
    public int Area => Side * Side;
}

Değişmez (immutable) kayıtlarda “genişliği ayrı ayarla” diye bir söz yok. İki tür de ortak arayüzde yalnızca tutabildiği tek sözü veriyor: alanını hesaplamak.

C# kodunda LSP ihlalinin iki tipik izi vardır. Biri, ezilen (override) bir metodun NotSupportedException fırlatmasıdır. Diğeri, çağıran kodda if (shape is Square) gibi tür kontrollerinin çoğalmasıdır. Bu gerilim .NET’in kendisinde de görülür. Tek boyutlu diziler IList<T> arayüzünü uygular. Ama bir diziyi bu arayüz üzerinden kullanıp Add çağırdığınızda NotSupportedException alırsınız. IReadOnlyList<T> gibi salt okunur arayüzler, yalnızca okuyan kodun bu riski taşımamasını sağlar.

Sahadan bir benzetme gerekiyorsa şunu düşünün: 4–20 mA bekleyen bir analog girişe, 0–20 mA çıkış veren bir transmitter bağlanıyor. Kablo aynı klemense girer ve PLC bir değer okur. Ama aralığın alt ucu artık başka bir şey anlatır. Bağlantı uyumludur, sözleşme değil.

Arayüz ayrımı (ISP) ve NotSupportedException izi

Bir önceki bölümdeki NotSupportedException izi çoğu zaman şişkin bir arayüzden gelir. Bir hattaki bütün cihazlar için tek bir arayüz tanımlandığını düşünün:

public interface IDevice
{
    void Start();
    void Stop();
    double ReadTemperature();
    void Calibrate();
}

public class Conveyor : IDevice
{
    public void Start() { /* motoru çalıştır */ }
    public void Stop() { /* motoru durdur */ }

    public double ReadTemperature() =>
        throw new NotSupportedException();

    public void Calibrate() =>
        throw new NotSupportedException();
}

Konveyörün sıcaklık sensörü ya da kalibrasyon adımı yok, ama arayüz bunları zorunlu kılıyor. Sorun iki taraflı. Conveyor anlamsız metotlar taşıyor. IDevice alan her istemci de bu metotların çalışacağını varsayıyor ve çalışma anında istisnayla karşılaşabiliyor. ISP’nin tanımı tam da bunu anlatır: İstemciler, kullanmadıkları metotlara bağımlı olmaya zorlanmamalıdır.

public interface IStartStop
{
    void Start();
    void Stop();
}

public interface ITemperatureSensor
{
    double ReadTemperature();
}

public class Conveyor : IStartStop
{
    public void Start() { /* motoru çalıştır */ }
    public void Stop() { /* motoru durdur */ }
}

// Sıcaklık ekranı yalnız ihtiyacını ister
public class TemperatureView
{
    private readonly ITemperatureSensor _sensor;

    public TemperatureView(ITemperatureSensor sensor)
        => _sensor = sensor;
}

Sıcaklık ölçen bir fırın iki arayüzü birden uygular, konveyör ise yalnızca birini. Sıcaklık ekranı hangi cihazın kaç yeteneği olduğunu bilmez. Tek istediği, okuyabileceği bir sensördür.

Ayrım istemciye göre yapılır, metot sayısına göre değil. Start ve Stop her zaman birlikte kullanılıyorsa onları iki ayrı arayüze bölmek bir şey kazandırmaz. İşe yarayan ölçüt şu: Bu arayüzü alan sınıfların hepsi, metotların hepsini kullanıyor mu?

Bağımlılığın tersine çevrilmesi (DIP): soyutlamayı kim tanımlar

Aşağıdaki sınıf, bir hattın aşırı ısınıp ısınmadığına karar veriyor. Sıcaklığı doğrudan bir Modbus okuyucusundan alıyor:

public class LineMonitor
{
    private readonly ModbusTemperatureReader _reader =
        new ModbusTemperatureReader("192.168.0.10");

    public bool IsOverheated() =>
        _reader.ReadHoldingRegister(100) / 10.0 > 85.0;
}

Bu hâliyle LineMonitor, ağ üzerinden bir PLC’ye bağlanmadan test edilemez. Isınma kararının içine register adresi ve ölçek katsayısı karışmış durumda. Protokol OPC UA’ya geçtiğinde alarm mantığının da değişmesi gerekir. Kısacası üst seviyedeki karar (ne zaman alarm verileceği), alt seviyedeki bir ayrıntıya (değerin hangi protokolle okunduğuna) bağlanmış.

DIP iki cümleden oluşur. Üst seviye modüller alt seviye modüllere bağımlı olmamalı; ikisi de soyutlamalara bağımlı olmalıdır. Soyutlamalar ayrıntılara değil, ayrıntılar soyutlamalara bağımlı olmalıdır.

// Arayüzü, onu kullanan taraf tanımlar
public interface ITemperatureSource
{
    double ReadCelsius();
}

public class LineMonitor
{
    private readonly ITemperatureSource _source;

    public LineMonitor(ITemperatureSource source)
        => _source = source;

    public bool IsOverheated() =>
        _source.ReadCelsius() > 85.0;
}

// Testte makine gerekmez
public class FakeTemperatureSource : ITemperatureSource
{
    public double ReadCelsius() => 90.0;
}

Gerçek okuyucu ModbusTemperatureReader da aynı arayüzü uygular. Register okuma ve ölçekleme onun içinde kalır. Tersine çevrilen şey programın çalışma sırası değil, kaynak kodun bağımlılık yönüdür. Çalışma anında LineMonitor yine Modbus okuyucusunu çağırır. Ama derleme anında Modbus sınıfı, LineMonitor tarafının tanımladığı arayüze bağımlıdır. Bu yüzden arayüz, onu kullanan tarafın diliyle yazılır: Metodun adı ReadHoldingRegister değil, ReadCelsius olur. FakeTemperatureSource sayesinde alarm eşiği, makine olmadan bir birim testinde denenebilir.

DIP ile bağımlılık enjeksiyonu (dependency injection) sık karıştırılır. DIP bir tasarım ilkesidir. Enjeksiyon ise bağımlılığı sınıfın içinde new ile yaratmak yerine dışarıdan, genellikle yapıcı metot üzerinden vermektir. .NET’te bu işi yapan yerleşik bir bağımlılık enjeksiyonu kabı vardır ve kayıt tek satırdır:

builder.Services.AddSingleton<
    ITemperatureSource, ModbusTemperatureReader>();

Kap zorunlu değildir. Küçük bir konsol uygulamasında bağımlılığı new LineMonitor(new FakeTemperatureSource()) diye elle vermek de DIP’e uygundur.

SOLID ne zaman aşırıya kaçar

Beş ilke de değişim maliyetini düşürmek için var. Değişim beklenmeyen bir yerde uygulandıklarında bu maliyeti düşürmezler, yalnızca başka bir yere taşırlar. Kodu okuyan kişi tek bir davranışı bulmak için beş dosya ve üç arayüz arasında gezmek zorunda kalır.

Sık karşılaşılan biçimi arayüz enflasyonudur: Her sınıfın yanında, tek bir uygulaması olan bir I… arayüzü durur. Örneğin yalnızca OeeCalculator tarafından uygulanan bir IOeeCalculator hiçbir şey kazandırmaz. Hesap saf bir fonksiyondur ve sınıf zaten doğrudan test edilebilir. Arayüz genellikle üç durumda kendini öder:

  • Dış bir kaynağa dokunuluyorsa: veritabanı, dosya, ağ, PLC, sistem saati.
  • İkinci bir gerçek uygulama varsa ya da somut olarak planlanmışsa.
  • Farklı ekiplerin veya projelerin buluştuğu bir sınırda duruyorsa.

Bunların dışında somut sınıfla başlamak çoğu zaman daha iyidir. Arayüz çıkarmak, Visual Studio ve Rider gibi IDE’lerde tek adımlık bir refactoring işlemidir. İhtiyaç doğduğunda yapılabilir.

Aşağıdaki tablo, her ilke için “şimdi uygula” ve “bekle” sinyallerini yan yana koyuyor:

İlkeUygulama sinyaliBeklemek daha doğru
SRPFarklı ekipler aynı sınıfı değiştiriyorTek ekip, tek amaç, küçük sınıf
OCPAynı switch‘e yine case ekleniyorTek varyasyon var, yenisi görünmüyor
LSPKalıtım var, metot eziliyorErtelenmez
ISPUygulayan sınıflar istisna fırlatıyorArayüzün tek istemcisi var
DIPDış kaynak testte değiştirilmeliSaf hesap, tek uygulama

LSP satırı diğerlerinden farklı. Öteki dört ilke birer tasarım tercihidir ve bağlama göre ertelenebilir. LSP ise bir doğruluk kuralıdır. Kalıtım kullanıldığı anda geçerli olur. Bozulduğunda ortaya çıkan şey esneklik kaybı değil, yanlış çalışan bir programdır.

Otomasyon ve gömülü yazılımda aynı ilkeler

C# yalnızca web ve masaüstü uygulamalarında kullanılmaz. Test istasyonlarında, operatör ekranlarında ve sahadan veri toplayan IIoT uygulamalarında da sık karşılaşılır. Bu projelerde en pahalı değişiklik genellikle haberleşme tarafında olur: Makine, protokol ya da PLC markası değişir. DIP bölümündeki ITemperatureSource ayrımı tam da bu sınırı çizer. Makine yokken simülasyon kaynağıyla geliştirmek ve devreye alma sırasında gerçek okuyucuya geçmek, aynı iş mantığıyla yapılabilir.

PLC tarafında da aynı fikirler geçerli. IEC 61131-3 standardının 2013’te yayımlanan üçüncü baskısı sınıf, metot, arayüz ve kalıtım gibi nesne yönelimli eklentiler getirdi. Bu eklentileri destekleyen ortamlarda, örneğin farklı sürücü tipleri için ortak bir motor arayüzü tanımlanabilir. Ancak her platform bu eklentileri aynı ölçüde desteklemez. Kullanılan ortamın dokümantasyonuna bakmak gerekir. Ladder ile başlayan biri için bu konu ilk basamak değildir. PLC öğrenme sırası ayrı bir yazıda anlatılıyor.

Gömülü C’de sınıf yoktur ama arayüz fikri vardır. Fonksiyon işaretçilerinden oluşan bir struct, farklı donanım sürücülerinin aynı çağrılarla kullanılmasını sağlar. C, C++ ve C# farkları ayrı bir yazıda karşılaştırılıyor. Burada bir sınıra dikkat etmek gerekiyor: Her soyutlama bir dolaylı çağrı ekler. Çoğu uygulamada bu maliyet önemsizdir. Ama kesme (interrupt) rutinlerine ve çok sık dönen kontrol döngülerine, ölçüm yapmadan soyutlama eklenmemelidir.

Sık sorulan sorular

SOLID prensipleri ne demek, bir tasarım deseni mi?

Hayır. SOLID, bir tasarımın değişime ne kadar dayanıklı olduğunu değerlendirmek için kullanılan beş ilkenin kısaltmasıdır. Tasarım desenleri (Strategy, Adapter, Factory gibi) ise belirli problemlere hazır çözüm kalıplarıdır. İkisi birbirini tamamlar. OCP bölümündeki alarm kanalı yapısı Strategy desenine çok benzer. DIP de bir üretici kütüphanesini kendi arayüzünüzün arkasına saklayan Adapter desenini anlamlı kılar.

SOLID prensipleri Java’da farklı mı uygulanır?

İlkeler dilden bağımsızdır ve Java’da da aynen geçerlidir. Farklar sözdiziminde ve ekosistemdedir. Java kalıtım için extends, arayüz için implements yazar; C# ikisi için de iki nokta üst üste kullanır. İki dil de arayüz içinde varsayılan metot gövdesine izin verir (Java’da 8, C#’ta 8.0 sürümünden beri). Bağımlılık enjeksiyonu için .NET’te yerleşik bir kap bulunur. Java projelerinde bu rolü çoğunlukla Spring gibi çatılar üstlenir.

SOLID prensiplerini örnek bir projede nasıl çalışabilirim?

Tanımları ezberlemek yerine değişiklik üzerinden çalışmak daha öğreticidir. Sensör verisini bir CSV dosyasından okuyup rapor basan küçük bir konsol uygulaması yazın. Sonra kendinize sırayla üç değişiklik isteyin: yeni bir rapor biçimi, yeni bir veri kaynağı ve yeni bir alarm kuralı. Her değişiklikte hangi dosyalara dokunduğunuzu not edin. Birbiriyle ilgisiz görünen yerlere dokunmak zorunda kaldığınız değişiklik, hangi ilkenin eksik olduğunu genellikle kendisi gösterir.

İlkeler sınıftan katmana taşındığında

Buraya kadarki örneklerin hepsi tek tek sınıflardı. Aynı ilkeler proje ölçeğinde de işler. Microsoft’un .NET mimari ilkeleri rehberi tek sorumluluğu katman düzeyinde uygular. Sunum kullanıcı arayüzü projesinde, veri erişimi altyapı projesinde, iş kuralları ise uygulamanın çekirdeğinde kalır. Aynı rehbere göre iş kurallarının bulunduğu proje ideal olarak diğer projelere bağımlı olmamalıdır. Altyapının, iş katmanında tanımlanan soyutlamalara bağımlı olması da bunu kolaylaştırır. Bu, DIP’in proje referansları düzeyindeki karşılığıdır.

Katmanlı bir .NET projesinin baştan sona nasıl kurulduğunu uygulamalı görmek isterseniz Asp.net Core 8.0 Framework Eğitim Seti bir Web API projesini Entity, Data Access, Business ve WebApi katmanlarıyla adım adım ele alıyor. Müfredatta “Dependency Resolvers” başlıklı bir ders ve bir “PLC Katmanı Geliştirmesi” bölümü de bulunuyor.

Yorum Yaz