SOLID ile Değişime Dayanıklı Mimari

SOLID ile Değişime Dayanıklı Mimari

SOLID ilkeleri sınıf sayısını artırmak için değil, değişiklik nedenlerini ve bağımlılık yönünü kontrol altında tutmak için değerlidir; gereksiz soyutlama aynı ölçüde tasarım borcu üretir.

SOLID ilkelerini değerlendirirken belirleyici ölçüt sınıf diyagramının ne kadar “temiz” göründüğü değil, bir değişiklik talebinin sistemde ne kadar uzağa yayıldığıdır. Küçük bir iş kuralı değişikliği ilgisiz sınıflara dokunuyor, testleri geniş bir alanda kırıyor veya aynı bağımlılığın farklı yerlerde yeniden düzenlenmesini gerektiriyorsa tasarım maliyeti kod satırından bağımsız olarak büyür.

Bu yüzden SOLID'i beş ayrı slogan olarak değil, değişim yönünü kontrol eden tasarım kısıtları olarak okuyorum. Tek sorumluluk değişiklik nedenlerini ayırır; açık/kapalı ilkesi yeni davranışın mevcut kodu parçalamadan eklenebilmesini hedefler; Liskov, arayüz ayırma ve bağımlılıkların ters çevrilmesi ise yerine geçebilirlik ile bağımlılık yönünün sınırlarını belirler.

Bu prensipler mekanik kurallar değildir. Her sınıf küçük olmak zorunda değildir. Her sınıf için interface yazmak gerekmez. Her switch ifadesi kötü tasarım değildir. Amaç daha fazla sınıf üretmek değil, değişikliğin doğru yerde kalmasını sağlamaktır.

Single Responsibility Principle (Tek Sorumluluk Prensibi)

Tek Sorumluluk Prensibi, bir sınıfın yalnızca bir değişiklik nedenine sahip olması gerektiğini söyler. Bu ilke çoğu zaman "bir sınıf tek iş yapmalıdır" biçiminde açıklanır. Buradaki iş, tek bir method anlamına gelmez. Aynı amaç için birlikte çalışan birkaç method, tek sorumluluk içinde kalabilir. Önemli olan, sınıfın farklı aktörlerden ve farklı gereksinimlerden etkilenmemesidir.

Kullanıcı kaydı yaygın bir örnektir. Aşağıdaki sınıf kullanıcıyı doğrular, veritabanına kaydeder ve e-posta gönderir. Bu işlemler farklı nedenlerle değişir. Doğrulama kuralları iş gereksinimlerine bağlıdır. Kayıt işlemi veri saklama teknolojisine bağlıdır. E-posta gönderimi ise mesaj şablonuna ve iletişim altyapısına bağlıdır.

public final class UserService {
    public void register(final User user) {
        if (user.getEmail() == null || !user.getEmail().contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }

System.out.println("User saved: " + user.getEmail());
        System.out.println("Welcome email sent: " + user.getEmail());
    }
}

public final class User {
    private final String name;
    private final String email;

public User(final String name, final String email) {
        this.name = name;
        this.email = email;
    }

public String getName() {
        return name;
    }

public String getEmail() {
        return email;
    }
}

Daha doğru tasarımda her değişiklik nedeni ayrı bir sınıfa taşınır. UserRegistrationService yalnızca kayıt sürecini yönetir. Doğrulama, veri saklama ve bildirim ayrı sorumluluklardır. Orkestrasyonun kendisi de geçerli bir sorumluluktur. Ancak iş ayrıntıları orkestrasyon sınıfına gömülmemelidir.

public final class UserValidator {
    public void validate(final User user) {
        if (user.getEmail() == null || !user.getEmail().contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
}

public final class UserRepository {
    public void save(final User user) {
        System.out.println("User saved: " + user.getEmail());
    }
}

public final class EmailService {
    public void sendWelcomeEmail(final User user) {
        System.out.println("Welcome email sent: " + user.getEmail());
    }
}

public final class UserRegistrationService {
    private final UserValidator validator;
    private final UserRepository repository;
    private final EmailService emailService;

public UserRegistrationService(final UserValidator validator, final UserRepository repository, final EmailService emailService) {
        this.validator = validator;
        this.repository = repository;
        this.emailService = emailService;
    }

public void register(final User user) {
        validator.validate(user);
        repository.save(user);
        emailService.sendWelcomeEmail(user);
    }
}

SRP ihlalinin yaygın sonucu, zamanla büyüyen Manager, Helper, Processor ve Service sınıflarıdır. Bu sınıflar veritabanı, iş kuralı, dosya erişimi ve ağ işlemlerini birlikte taşır. Testleri zorlaşır. Constructor bağımlılıkları artar. Ancak aşırı parçalama da çözüm değildir. Her methodu ayrı sınıfa taşımak kodu dağıtır. Doğru sınır, birlikte değişen davranışlara göre belirlenir.

Open Closed Principle (Açık Kapalı Prensibi)

Açık Kapalı Prensibi, yazılım bileşenlerinin genişlemeye açık, değiştirilmeye kapalı olması gerektiğini belirtir. Buradaki kapalılık mutlak değildir. Kod hata düzeltmek veya temel davranışı değiştirmek için düzenlenebilir. İlkenin hedefi, yeni bir seçenek eklendiğinde kararlı kodun sürekli değiştirilmesini önlemektir.

Alan hesaplama örneğinde merkezî sınıf, şeklin gerçek türünü kontrol eder. Yeni bir şekil eklendiğinde aynı method değiştirilir. Şekil sayısı arttıkça koşullu yapı büyür. Hesaplayıcı, desteklediği bütün türleri bilmek zorunda kalır.

public final class Rectangle {
    private final double width;
    private final double height;

public Rectangle(final double width, final double height) {
        this.width = width;
        this.height = height;
    }

public double getWidth() {
        return width;
    }

public double getHeight() {
        return height;
    }
}

public final class Circle {
    private final double radius;

public Circle(final double radius) {
        this.radius = radius;
    }

public double getRadius() {
        return radius;
    }
}

public final class AreaCalculator {
    public double calculate(final Object shape) {
        if (shape instanceof Rectangle) {
            final Rectangle rectangle = (Rectangle) shape;
            return rectangle.getWidth() * rectangle.getHeight();
        }

if (shape instanceof Circle) {
            final Circle circle = (Circle) shape;
            return Math.PI * circle.getRadius() * circle.getRadius();
        }

throw new IllegalArgumentException("Unsupported shape");
    }
}

Daha uygun tasarımda değişken davranış Shape sözleşmesine taşınır. Her şekil kendi alanını hesaplar. AreaCalculator yalnızca ortak sözleşmeyi kullanır. Yeni bir üçgen veya elips eklendiğinde hesaplayıcı değişmez.

public interface Shape {
    double area();
}

public final class Rectangle implements Shape {
    private final double width;
    private final double height;

public Rectangle(final double width, final double height) {
        this.width = width;
        this.height = height;
    }

@Override
    public double area() {
        return width * height;
    }
}

public final class Circle implements Shape {
    private final double radius;

public Circle(final double radius) {
        this.radius = radius;
    }

@Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

public final class Triangle implements Shape {
    private final double base;
    private final double height;

public Triangle(final double base, final double height) {
        this.base = base;
        this.height = height;
    }

@Override
    public double area() {
        return base * height / 2.0;
    }
}

public final class AreaCalculator {
    public double calculate(final List<Shape> shapes) {
        double total = 0.0;

for (int i = 0; i < shapes.size(); i++) {
            total += shapes.get(i).area();
        }

return total;
    }
}

OCP yalnızca kalıtımla uygulanmaz. Strategy, Decorator, Factory ve Adapter gibi desenler de genişleme noktası oluşturabilir. Fakat her değişiklik olasılığı için önceden interface yazmak doğru değildir. Değişim kümesi küçük ve kapalıysa switch daha sade olabilir. OCP, geleceği tahmin etmek değil, sık değişen davranışı kararlı koddan ayırmaktır.

Liskov Substitution Principle (Liskov Yerine Geçme Prensibi)

Liskov Yerine Geçme Prensibi, bir alt türün üst türün kullanıldığı yerde programın doğruluğunu bozmadan kullanılmasını ister. Derleyicinin kalıtıma izin vermesi yeterli değildir. Alt sınıf, üst sınıfın davranışını da korumalıdır. Girdi koşulları, sonuç garantileri, hata davranışı ve nesne kuralları değişmemelidir.

Kare ve dikdörtgen örneği bu sorunu açık biçimde gösterir. Dikdörtgende genişlik ve yükseklik bağımsızdır. Karede ise iki kenar her zaman eşittir. Square, Rectangle sınıfından türetilirse setter davranışlarını değiştirmek zorunda kalır.

public class Rectangle {
    private int width;
    private int height;

public void setWidth(final int width) {
        this.width = width;
    }

public void setHeight(final int height) {
        this.height = height;
    }

public int area() {
        return width * height;
    }
}

public final class Square extends Rectangle {
    @Override
    public void setWidth(final int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

@Override
    public void setHeight(final int height) {
        super.setHeight(height);
        super.setWidth(height);
    }
}

Bir method, genişlik ve yüksekliğin bağımsız değişebileceğini varsayabilir. Normal dikdörtgen için sonuç 50 olur. Kare geçirildiğinde ikinci setter ilk değeri de değiştirir ve sonuç 25 olur. Alt sınıf, üst sınıfın beklenen davranışını bozmuştur.

public final class ShapeService {
    public int resizeAndCalculate(final Rectangle rectangle) {
        rectangle.setWidth(10);
        rectangle.setHeight(5);
        return rectangle.area();
    }
}

Daha doğru çözüm, iki sınıfı yalnızca gerçekten ortak olan davranış altında birleştirmektir. Kare ve dikdörtgen birer şekildir. Fakat aynı mutable kenar sözleşmesine sahip değildir.

public interface Shape {
    int area();
}

public final class Rectangle implements Shape {
    private final int width;
    private final int height;

public Rectangle(final int width, final int height) {
        this.width = width;
        this.height = height;
    }

@Override
    public int area() {
        return width * height;
    }
}

public final class Square implements Shape {
    private final int side;

public Square(final int side) {
        this.side = side;
    }

@Override
    public int area() {
        return side * side;
    }
}

LSP ihlalinin açık işaretleri vardır. Alt sınıfın method içinde UnsupportedOperationException fırlatması güçlü bir uyarıdır. Boş bırakılan override da aynı sorunu gösterir. İstemci gerçek türü instanceof ile kontrol etmek zorunda kalıyorsa ortak sözleşme zayıftır. Gerçek sistemlerde iş parçacığı-safety, zaman aşımı, idempotency ve blocking davranışı da bu sözleşmenin parçasıdır.

Interface Segregation Principle (Arayüz Ayırma Prensibi)

Arayüz Ayırma Prensibi, istemcilerin kullanmadıkları methodlara bağımlı olmamasını ister. Amaç her interface'i tek methoda indirmek değildir. Amaç, farklı yetenekleri gereksiz biçimde aynı sözleşmede toplamamaktır. Bir sınıfın bazı methodları boş bırakması veya desteklenmeyen işlem hatası üretmesi, arayüzün fazla geniş olduğuna işaret eder.

Çok işlevli yazıcı örneğinde tek bir Machine interface'i yazdırma, tarama ve faks işlemlerini birlikte sunar. Basit bir yazıcı yalnızca yazdırabilir. Buna rağmen diğer methodları da implement etmek zorunda kalır.

public interface Machine {
    void print(Document document);
    void scan(Document document);
    void fax(Document document);
}

public final class BasicPrinter implements Machine {
    @Override
    public void print(final Document document) {
        System.out.println("Printing");
    }

@Override
    public void scan(final Document document) {
        throw new UnsupportedOperationException();
    }

@Override
    public void fax(final Document document) {
        throw new UnsupportedOperationException();
    }
}

public final class Document {
    private final String content;

public Document(final String content) {
        this.content = content;
    }

public String getContent() {
        return content;
    }
}

Arayüzler yeteneklere göre ayrıldığında her sınıf yalnızca desteklediği davranışları uygular. Basit yazıcı Printer sözleşmesini kullanır. Çok işlevli cihaz üç sözleşmeyi birlikte uygulayabilir.

public interface Printer {
    void print(Document document);
}

public interface Scanner {
    void scan(Document document);
}

public interface Fax {
    void fax(Document document);
}

public final class BasicPrinter implements Printer {
    @Override
    public void print(final Document document) {
        System.out.println("Printing");
    }
}

public final class MultiFunctionPrinter implements Printer, Scanner, Fax {
    @Override
    public void print(final Document document) {
        System.out.println("Printing");
    }

@Override
    public void scan(final Document document) {
        System.out.println("Scanning");
    }

@Override
    public void fax(final Document document) {
        System.out.println("Sending fax");
    }
}

ISP, yalnızca okunabilirliği artırmaz. Yetki sınırlarını da daraltır. Salt okuma yapan bir servis, silme ve güncelleme yeteneklerini içeren geniş bir depo almamalıdır. Ancak aşırı ayrım da zararlıdır. Her method için ayrı interface üretmek, interface explosion antipattern'ine dönüşebilir. Doğru ayrım, istemcilerin birlikte kullandığı davranışlara göre yapılır.

Dependency Inversion Principle (Bağımlılıkların Tersine Çevrilmesi Prensibi)

Bağımlılıkların Tersine Çevrilmesi Prensibi, yüksek seviyeli iş kurallarının düşük seviyeli teknik ayrıntılara doğrudan bağlanmamasını ister. Yüksek seviyeli modül sistemin ne yapacağını söyler. Düşük seviyeli modül bunun e-posta, SMS, veritabanı veya dosya sistemiyle nasıl yapılacağını belirler.

Aşağıdaki bildirim servisi somut sınıfları kendi içinde oluşturur. Bu nedenle yeni bir kanal eklendiğinde servis değiştirilir. Ayrıca test sırasında gerçek göndericileri değiştirmek zordur.

public final class EmailSender {
    public void send(final String message) {
        System.out.println("Email: " + message);
    }
}

public final class SmsSender {
    public void send(final String message) {
        System.out.println("SMS: " + message);
    }
}

public final class NotificationService {
    private final EmailSender emailSender = new EmailSender();
    private final SmsSender smsSender = new SmsSender();

public void notifyUser(final String message) {
        emailSender.send(message);
        smsSender.send(message);
    }
}

Daha doğru tasarımda yüksek seviyeli servis yalnızca MessageSender sözleşmesini bilir. Somut göndericiler dışarıda oluşturulur ve constructor ile verilir. Yeni bir kanal, mevcut servisi değiştirmeden eklenebilir.

public interface MessageSender {
    void send(String message);
}

public final class EmailSender implements MessageSender {
    @Override
    public void send(final String message) {
        System.out.println("Email: " + message);
    }
}

public final class SmsSender implements MessageSender {
    @Override
    public void send(final String message) {
        System.out.println("SMS: " + message);
    }
}

public final class PushNotificationSender implements MessageSender {
    @Override
    public void send(final String message) {
        System.out.println("Push notification: " + message);
    }
}

public final class NotificationService {
    private final List<MessageSender> senders;

public NotificationService(final List<MessageSender> senders) {
        this.senders = new ArrayList<MessageSender>(senders);
    }

public void notifyUser(final String message) {
        for (int i = 0; i < senders.size(); i++) {
            senders.get(i).send(message);
        }
    }
}

public final class Application {
    public static void main(final String[] args) {
        final List<MessageSender> senders = new ArrayList<MessageSender>();
        senders.add(new EmailSender());
        senders.add(new SmsSender());
        senders.add(new PushNotificationSender());

final NotificationService service = new NotificationService(senders);
        service.notifyUser("Order completed");
    }
}

DIP ile Dependency Injection aynı şey değildir. DIP, bağımlılık yönünü belirleyen prensiptir. Dependency Injection, bağımlılıkları dışarıdan verme tekniğidir. IoC container kullanmak zorunlu değildir. Küçük sistemlerde manual wiring daha açık olabilir. İş sınıfları container içinden servis aramamalıdır. Bu yaklaşım Service Locator antipattern'ine dönüşür ve gerçek bağımlılıkları gizler.

Bir tasarımın SOLID'e uygunluğu sınıf sayısından veya kullanılan arayüz miktarından anlaşılmaz. Daha güvenilir ölçüt, gerçek bir değişiklik geldiğinde kaç davranışın etkilendiği, bağımlılıkların hangi yöne aktığı ve testlerin hangi sözleşmeleri koruyabildiğidir. İlke ancak değişiklik maliyetini düşürüyorsa mimari değer üretir.

Hexagonal Architecture ile ilişki

Dependency Inversion ilkesinin uygulama çekirdeği ile dış dünya arasındaki sınırları nasıl etkilediğini Hexagonal Architecture maddesinde ports/adapters bağlamıyla ayrıca ayırıyorum.

Kaynakça

  • Barbara H. Liskov; Jeannette M. Wing. (1994). A Behavioral Notion of Subtyping. ACM Transactions on Programming Languages and Systems, 16(6), 1811-1841. doi:10.1145/197320.197383
  • Robert C. Martin. (2000). Design Principles and Design Patterns. Object Mentor.
  • Robert C. Martin. (2002). Agile Software Development: Principles, Patterns, and Practices. Prentice Hall.
Bu sayfanın QR kodu