post-images/solid-principles/cover.jpg

SOLID Prensipleri

Merhaba bugün sana yazılım dünyasında kabul edilmiş en önemli prensiplerden olan SOLID prensiplerinden bahsedeceğim. SOLID aslında tek başına katı-sağlam anlamlarını taşıyor olsada doğrudan bizim ilgi alanımızla alakalı diyemiyorum. Sadece bir kaç prensibin baş harflerininin bir araya gelmesiyle oluşmuş bir kısaltmadan ibaret ve hatırlaması daha kolay 🤷🏻‍♂️. Bu yazıyı yazma nedenlerimden bir tanesi de bu aslında geliştirme yaparken ne kadar kullansam da bir süre sonra isimlerini unutabiliyorum. Hem senin için hem de benim için güzel bir not olması dileğiyle...

SOLID prensiplerinin ne yeni ne de eski olduğu konusunda bir şey söyleyemem. Bundan yaklaşık 22 yıl önce 2000 yılında Bob Amcamız (Robert C. Martin ayrıca Bob Amca olarak bilinir) tarafından ortaya atılmıştır. Amcamız seni beni düşünerek bizlere daha iyi kod yazmak için bu prensipleri yayımlamış. Daha sürdürülebilir, anlaşılır, esnek, tekrar kullanılabilir kod yazmayı, code smellerden kurtulmayı, refactoring yapmayı agile ve adaptive (uyarlanabilir) yazılım geliştirme yapabilmemize ön ayak olmuştur.

Ne kadar mükemmel bir reçete değil mi? Yukarıdaki şekilde bir uygulama geliştirmenin tarifi çok basit öncelikle neymiş bu SOLID kısa bir özet geçip sonrasında detaylarına gireceğim.

TL;DR

  • Single Responsibility (Tekil Sorumluluk): Bir nesnenin veya metodun sadece tek bir sorumlulu olmalıdır.
  • Open/Closed (Açık / Kapalılık): Nesnemiz veya metodumuz geliştirmeye açık, ancak değişikliğe kapalı olmalıdır.
  • Liskov Substitution (Yerine Geçme): Child olan nesnenin parent yerine geçtiği durumunda parent sınıfın özelliklerinin tamamını kullanabiliyor olmalıdır.
  • Interface Segregation (Arayüz Ayırma): Sorumluluklar tek bir interface'de değil daha küçük anlamlı parçalara bölünmesi gereklidir.
  • Dependency Inversion (Bağımlılığı Tersine Çevirme): Bir sınıfın bir başka sınıfa doğrudan bağlı olmaması yerine abstraction üzerinden bağımlılıkların yönetilmesi gerekir.

İsimlerinden de tam olarak olmasada kısmen ne anlama geldikleri anlaşılıyor ne dersin? Hayır mı? Senin için herhangi bir şey uyandırmadı veya tam anlamıyla kavramak istiyorsan. Senin için ilk maddemle başlayıp bir takım örneklerle pekiştireyim. Gerekirse üzerine tartışırız.

Single Responsibility

Single Responsibility ideal dünyada herkesin tek bir sorumluluğunun olduğunu ve sadece bununla ilgilenmesi gerektiğini savunur. Yani sen bir yazılım mühendisiysen senden yazılım geliştirmen bekleniyor. Gidip inşaat tasarlarsan bu seni tanımlamada zorluk çıkarır. İkisini aynı anda yapmaya çalıştığında yapman gereken asıl işi tam performanslı yapamamana neden olur ve ortam kaosa doğru sürüklenir. Bu yaklaşımla yazmış olduğun bir sınıf veya metot sadece yapmakla yükümlü olduğu şeyleri yerine getirmelidir. Olur da bir metod içerisinde yahut sınıf içerisinde kendi yaratılış nedeninin dışında bir aksiyon tanımlanırsa bu durum single responsibility'i bozacaktır.

public class Blogger {
    @Override
    public Blog getBlogById(Long id) {
        return null;
    }
    @Override
    public void addBlog(Blog blog) {
	...
    }
    @Override
    public void sendEmail(Blog blog, User recipient) {}
}

Bence sınıf gayet hoş görünüyor herhangi bir sorun var mı? Bunu sormaya gerek yok bariz belli ne dersin? Blog ile ilgili bir sınıf içerisinde mail gönderme aksiyonunun ne alakası var değil mi? Peki nasıl bir yöntem izlemek doğru olurdu?

public class Blogger {
    @Override
    public Blog getBlogById(Long id) {
        return null;
    }

    @Override
    public void addBlog(Blog blog) {}
}
public interface EmailSender {
    public void sendEmail(Blog blog, User recipient);
}

Blog ekleme ve bu blog ile ilgili mail gönderme sorumluluklarını ayırdım. Bu şekilde daha sürdürülebilir, esnek, organize ve tekrar kullanılabilir bir yapı kurmuş oldum. Mail gönderme akışı içerisine ekstra bir şeyler eklemek istediğimde bu gereksinimleri Blogger sınıfı içerisine ekleyip sınıfımı kaosa sürüklememiş olacağım.

Open/Closed

Açıklaması ve anlaması en kolay olan prensip olduğunu düşünüyorum. En net tanımıyla yapmış olduğunuz geliştirme genişletmeye açık, ancak değişikliğe kapalı olmalıdır. Sınıfı daha önce bir başkası kullanmış olabilir. Yapmış olduğum yeni geliştirme sonrasında kullanım alanlarında herhangi bir değişikliğe gerek duyulmaması gerekir. Mevcutta yazılan geliştirmeler değiştirilmemeli fakat yeni özellikler eklenebilmelidir.

Güzel bir örneği Baeldun - Open/Closed Principle in Java sitesinde buldum bu örnek üzerinden açıklayacağım.

public  interface  CalculatorOperation {}

Toplama işlemi için;

public class Addition implements CalculatorOperation {
    private double left;
    private double right;
    private double result = 0.0;

    public Addition(double left, double right) {
        this.left = left;
        this.right = right;
    }
    ...
}

Çıkarma işlemi için;

public class Subtraction implements CalculatorOperation {
    private double left;
    private double right;
    private double result = 0.0;

    public Subtraction(double left, double right) {
        this.left = left;
        this.right = right;
    }
    ...
}
public class Calculator {

    public void calculate(CalculatorOperation operation) {
        if (operation == null) {
            throw new InvalidParameterException("Can not perform operation");
        }

        if (operation instanceof Addition) {
            Addition addition = (Addition) operation;
            addition.setResult(addition.getLeft() + addition.getRight());
        } else if (operation instanceof Subtraction) {
            Subtraction subtraction = (Subtraction) operation;
            subtraction.setResult(subtraction.getLeft() - subtraction.getRight());
        }
    }
}

Elimizdeki sınıfları kullanarak bir hesap makinesi yapmış olduk. Sence nasıl oldu. Open/Closed Prensibi (OCP)'i işlediğimizden dolayı burada OCP'yi bozan bir durum gözüne çarpmış olmalı. Biraz yardımcı olmam gerekirse mesela toplama ve çıkartma dışında bölme işlemine de ihtiyacım olsaydı ne yapmam gerekirdi? Yeni bir bölme sınıfı yazmam, sonrasında da Calculator içerisine else if (operation instanceof Subtraction) şeklinde bir koşul eklemem gerekiyor değil mi?. Bölme sınıfı yazmak bizim için sorun değil fakat mevcutta yazılmış olan calculate metodunun değiştirilmesi OCP'ine aykırıdır. Bunun yerine şu şekilde bir yapı kurabilirdim.

İşlemlerimizde kullanmak için ortak bir metod oluşturduk bu metodu polymorphism ile işlemler içerisinde dolduruyor olacağız.

public  interface  CalculatorOperation { 
    void  perform(); 
}

Toplama işlemini yapacak sınıfımızı oluşturduk ve perform metodunu toplama işlemini gerçekleştirecek şekilde uyguladık.

public class Addition implements CalculatorOperation {
    private double left;
    private double right;
    private double result;

    ...

    @Override
    public void perform() {
        result = left + right;
    }
}

Aynı şekilde bölme işlemini yapacak sınıfımızı oluşturduk ve perform metodunu bölme işlemini gerçekleştirecek şekilde uyguladık.

public class Division implements CalculatorOperation {
    private double left;
    private double right;
    private double result;

    ...

    @Override
    public void perform() {
        if (right != 0) {
            result = left / right;
        }
    }
}

Calculate sınıfımızı düzenlememiz gerekirse;

public class Calculator {

    public void calculate(CalculatorOperation operation) {
        if (operation == null) {
            throw new InvalidParameterException("Cannot perform operation");
        }
        operation.perform();
    }
}

Artık istediğiniz kadar toplama, çıkartma, çarpma, bölme işlemini herhangi bir değişikliğe gerek duymadan sistemimize ekleyebiliriz.

Değişikliğe izin vermedik ve genişleme özelliğinide bozmamış olduk. Mükemmel 👌🏼

Liskov Substitution

Liskov'un yer değiştirme prensibi olarak geçen bu prensip temelde yer değiştirmeler esnasında görevlerin yerine tam anlamıyla getirilmesini savunuyor. Burada ki yer değiştirme bu aşamada senin için anlam ifade etmemiş olabilir. Bahsedilen yer değiştirme; child olan nesnenin parent olması durumunda parent sınıfın tüm özelliklerini kullanmak zorunda olmasıdır. Eğer herhangi bir tanesini dahi kullanılmıyorsa Liskov Substitution prensibini bozmuş oluruz.

Bir kuş sınıfımız olsun temsil ettiğimiz kuşun yeme ve uçma aksiyonlarını gerçekleştiren metodlarımız olduğunu varsayalım. Yazmış olduğumuz bu Bird sınıfımızı kullanırsak eğer;

public class Bird{
    public void eat(){}
    public void fly(){}
}
public class Eagle extends Bird{}

Kartal bir kuştur ve artık bizler Eagle.eat() veya Eagle.fly() metodlarını çağırabiliriz. Fakat uçamayan bir kuş olan penguen için eat() metodu mantıklı olsa da fly() kullanılamayacaktır. Çünkü penguen uçamayan bir kuştur.

public class Penguin extends Bird{} 

fly() metodu override edilecek ve kullanılmayacağı için içerisine muhtemelen bir exception atmanız gerekecektir. Alt sınıf (Penguin) üst sınıfın (Bird) metodlarının tamamını kullanmıyor olması Liskov Substitution prensibine aykırıdır.

Bu prensibi bozmadan nasıl yapabilirdim? Araya bir adet daha abstraction layer (soyutlama katmanı) eklememiz gerekmektedir. Şöyle ki:

public class Bird{
    public void eat(){}
}
public class FlyingBirds extends Bird{
    public void fly(){}
}
public class Eagle extends FlyingBirds{}
public class Penguin extends Bird{} 

Yukarıdaki şekilde bir yapı kullanarak alt sınıf ile üst sınıfın yeri değiştiğinde alt sınıf tamamen üst sınıfın metodlarını kullanmış ve prensibimizin gereksinimlerini karşılamış olacaktır.

Başka bir güzel örnekle pekiştirmem gerekirse BasePrinter içerisinde hem print() hem de scan() metodu olan bir yapı yerine arada bir Scan katmanıyla konu daha esnek yazma fırsatı edinebiliriz.

abstract class BasePrinter {
    abstract public function print();
}
interface Scan{
    public funtion scan();
}
class HpPrinter extends BasePrinter  {
    public function print(){}
}
class CanonPrinter extends BasePrinter implements Scan {
    public function print() {}
    public function scan() {}
}

Interface Segregation

Bu prensip kapsamında benim aklıma sürekli zorla güzellik yapmak deyimi geliyor. Yani bir interface hayal et tüm sorumlulukların bu interface içerisinde bulunduğunu varsay eğer ben bu interface içerisinde sadece birkaç metod kullanmak için küçük bir sınıfıma implement edersem geri kalan tüm metodları da override etmem gerekecek ve gereksiz yere bu metodlar benim sınıfımı kirletecek. Bunun yerine interfaceleri daha anlamlı parçalara bölmek gereksiz metodlardan kaçınmak gereklidir. Bu prensip özetle sorumlulukların tek bir interface'e atanmasını değil özelleştirilmiş anlamlı interface'ler ile bölünmesini önerir. Tüm metodların olduğu interface, küçük sınıf derken biraz boyut kavramını yansıtamamış olabilirim hemen bir örnekle pekiştirmek istiyorum.

Mesela bir önceki örnekte olan Printer örneğini tekrar ele alalım;

public interface PrinterTasks {
    void print(String printContent);
    void scan(String scanContent);
    void fax(String faxContent);
    void printDuplex(String printDuplexContent);
}

Printer'a ait işlemleri barındıran bir interface'imiz olduğunu varsayalım. Bu durumda tüm özelliklere sahip HPLaserJetPrinter implementasyonu için herhangi bir sorun olmayacaktır:

public class HPLaserJetPrinter extends PrinterTasks {
    public void print(String printContent) {}
    public void scan(String scanContent) {}
    public void fax(String faxContent) {}
    public void printDuplex(String printDuplexContent) {}
}

Fakat LiquidInkjetPrinter model printer için kullanılmayan fax() ve printDuplex() metodlarımız olacaktır:

public class LiquidInkjetPrinter extends PrinterTasks {
    public void print(String printContent) {}
    public void scan(String scanContent) {}

    public void fax(String faxContent) {
        throw new UnsupportedOperationException();
    }

    public void printDuplex(String printDuplexContent) {
        throw new UnsupportedOperationException();
    }
}

Gördüğün gibi kullanılmayan metodları gereksiz yere override etmiş olduk. Bu durum Interface Segregation prensibini bozmuş oldu. Bu sorunu çözmek için ortak olmayan özellikleri farklı interface'lere bölmek en mantıklı davranış olacaktır. Şöyle ki;

public interface PrinterTasks {
    void print(String printContent);
    void scan(String scanContent);
}
public interface FaxTasks {
    void fax(String faxContent);
}
public interface PrintDuplexTasks {
    void printDuplex(String printDuplexContent);
}

Yukarıdaki bölünme gayet mantıklı görünüyor. Kullanımlarını incelediğimizde...

public class HPLaserJetPrinter extends PrinterTasks, FaxTasks, PrintDuplexTasks {
    public void print(String printContent) {}
    public void scan(String scanContent) {}
    public void fax(String faxContent) {}
    public void printDuplex(String printDuplexContent) {}
}
public class LiquidInkjetPrinter extends PrinterTasks {
    public void print(String printContent) {}
    public void scan(String scanContent) {}
}

Gördüğün gibi her sınıf kendi özelliklerine sahip olan soyutlamayı uygulayarak gereksiz yere başka bağlılıklara zorlanmamış oldu.

Dependency Inversion

Bir sınıfın bir başka sınıfa doğrudan bağlı olmaması ve bağımlılıkların abstraction üzerinden sağlanması gerektiğini savunur. Abstract (soyut) nesneler concrete (somut) nesnelere değil concrete nesneler abstract nesnelere bağlı olmalıdır ve ayrıca yüksek seviyeli modüller, düşük seviyeli modüllerden hiçbir şey almamalıdır. Her ikisi de abstraction'a bağlı olmalıdır. Hemen örneğe geçiyorum örnekle daha iyi anlıyorum ben 😅

Bir BE developer'ımız olsun:

public class BackEndDeveloper {
    public void writeJava() {}
}

Bir tane de FE developer'ımız olsun:

public class FrontEndDeveloper {
    public void writeJavascript() {}
}

Elimizde iki adet developer var ve bu iki developer'ın bir projede çalışmasını istiyorsak eğer:

public class Project {
    private BackEndDeveloper backEndDeveloper = new BackEndDeveloper();
    private FrontEndDeveloper frontEndDeveloper = new FrontEndDeveloper();

    public void implement() {
        backEndDeveloper.writeJava();
        frontEndDeveloper.writeJavascript();
    }
}

Gayet şık görünüyor. Peki öyle mi? Mesela bir tane daha BE developer projeye dahil olsun dediğimizde ne yapacaksın? Projeye bir adet FullStack developer eklersek ne olacak?

public class Project {
    private BackEndDeveloper backEndDeveloper = new BackEndDeveloper();
    private BackEndDeveloper backEndDeveloper2 = new BackEndDeveloper();
    private FrontEndDeveloper frontEndDeveloper = new FrontEndDeveloper();
    private FullStackDeveloper fullStackDeveloper = new FullStackDeveloper();

    public void implement() {
        backEndDeveloper.writeJava();
        backEndDeveloper2.writeJava();
        frontEndDeveloper.writeJavascript();
        fullStackDeveloper.writeJava();
        fullStackDeveloper.writeJavascript();
    }
}

Durumun nerden doğduğunu, neden Dependency Inversion'a önem vermemiz gerektiğini anlamışsındır. Bu sorunu çözmenin çok kolay bir yolu var. Öncelikle kurduğum yapıyı bir gözden geçireyim. Projeye dahil olanların tamamı developer. Neden developerlar özelinde bir abstraction'a gitmeyeyim?

public interface Developer {
    void develop();
}
public class BackEndDeveloper implements Developer {
    @Override
    public void develop() {
        writeJava();
    }
    private void writeJava() {}
}
public class FrontEndDeveloper implements Developer {
    @Override
    public void develop() {
        writeJavascript();
    }

    public void writeJavascript() {}
}
public class Project {
    private List<Developer> developers;
    public Project(List<Developer> developers) {
        this.developers = developers;
    }
    public void implement() {
        developers.forEach(d->d.develop());
    }
}

Artık istediğiniz kadar developer'ı projeye ekleyebilirsiniz ve herhangi bir şeye dokunmanıza gerek yok. Süper değil mi?

Özet

SOLID prencipleri ne kadar önemli değil mi? Aslında her birinin ortaya çıkmasının bir mantıklı sebebi var. Bir standartlaştırma için koyulmuş prensipler değil daha çok yazılan kodların kalitesini arttırmak için gereken unsurlar gözüyle bakıyorum. Umarım seninde işine yaramıştır ve günlük hayatta pek çok sefer hatırlar ve uygularsın.

Addio🙋🏻‍♂️

SOLIDOOPJavaSingle ResponsibilityOpen/ClosedLiskov SubstitutionInterface SegregationDependency Inversion