sazak@labs:~
oturum

etiketler

Clean CodeSOLIDrefactoringsoftware designDependency InversionOOP

Software · Mar 5, 2025 · 11 dk okuma

SOLID Prensipleri: Ezberden Gerçek Yazılım Mühendisliğine

SOLID, daha fazla interface veya class yazmak değildir. Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation ve Dependency Inversion prensiplerini gerçek kod üzerinden, değişimin maliyetini merkeze alarak inceliyoruz.

SOLID Prensipleri: Ezberden Gerçek Yazılım Mühendisliğine

SOLID prensiplerinin amacı daha fazla interface, daha küçük class veya daha karmaşık bir mimari üretmek değildir. Amaç, yazılımda beklenen bir değişikliğin kaç yere dokunacağını, hangi davranışları bozabileceğini ve ne kadar test gerektireceğini kontrol etmektir. İyi bir tasarım, her değişikliği ucuzlatmaz; sık karşılaşılan değişiklikleri ucuzlatırken diğerlerinin maliyetini kabul edilebilir düzeyde tutar.

Bu ayrım günlük yazılım geliştirmede önemlidir. Bir ödeme yöntemi eklemek için mevcut ödeme akışını değiştirmek zorunda kalıyorsanız, bir interface'in arkasındaki implementasyon beklenmedik exception üretiyorsa veya küçük bir feature için birbirine benzeyen altı dosya açılıyorsa mesele prensipleri bilip bilmemek değildir. Mesele, bağımlılıkların gerçek değişim sınırlarına göre kurulup kurulmadığıdır.

Single Responsibility: Bir class neden değişir?

Single Responsibility Principle (SRP), bir class'ın yalnızca tek bir metodu veya tek bir işi olması gerektiğini söylemez. Bir modülün tek bir değişim nedeni olması gerektiğini söyler. Buradaki değişim nedeni, kodun hangi gereksinim veya karar değiştiğinde yeniden düzenleneceğidir.

Bir sipariş servisinin hem toplam tutarı hesapladığını hem veritabanına yazdığını hem de müşteriye e-posta gönderdiğini düşünelim.

plaintext
class OrderService:
    def complete(self, order):
        total = sum(item.price * item.quantity for item in order.items)
        database.save(order, total)
        email.send(order.customer_email, total)

Bu kodun problemi satır sayısı değildir. Fiyatlandırma kuralı değiştiğinde, veritabanı şeması yenilendiğinde veya e-posta sağlayıcısı değiştirildiğinde aynı class düzenlenir. Farklı kararlar aynı modülde birleşmiştir.

Daha anlamlı bir tasarım bu kararları ayırır.

plaintext
class PricingService:
    def total(self, order):
        return sum(item.price * item.quantity for item in order.items)

class OrderService:
    def __init__(self, pricing, repository, notifier):
        self.pricing = pricing
        self.repository = repository
        self.notifier = notifier

    def complete(self, order):
        total = self.pricing.total(order)
        self.repository.save(order, total)
        self.notifier.order_completed(order, total)

Artık fiyatlandırma politikası PricingService içinde bulunur. Persistence ayrıntıları repository'nin, bildirim davranışı notifier'ın sorumluluğundadır. OrderService ise sipariş tamamlama akışını koordine eder.

Burada önemli bir sınır var: Orkestrasyon yapmak başlı başına geçerli bir sorumluluktur. OrderService başka class'ları çağırıyor diye SRP ihlal edilmiş olmaz. Akışın sırası ve başarısızlık politikası aynı iş kararına bağlıysa birlikte kalabilir.

Öte yandan her metodu ayrı bir class'a taşımak da SRP değildir. Birlikte değişen davranışları ayırmak, tek bir feature üzerinde çalışırken gereksiz dosyalar arasında dolaşmaya neden olabilir.

SRP için kullanılabilecek pratik soru şudur: Bu modülde değişiklik isteyen karar sahipleri kimler? Fiyatlandırma ekibi ile bildirim altyapısını yöneten ekip birbirinden bağımsız değişiklikler yapıyorsa ayrım anlamlıdır. İki metod her zaman aynı gereksinimle değişiyorsa onları ayırmak büyük olasılıkla fayda sağlamaz.

Open/Closed: Yeni davranış eski kodu neden bozmasın?

Open/Closed Principle (OCP), yazılım bileşenlerinin genişletmeye açık, mevcut davranışı değiştirmeye ise mümkün olduğunca kapalı olmasını önerir. Bunun anlamı mevcut kodun hiçbir zaman düzenlenmeyeceği değildir. Kararlı bir karar mekanizmasının, her yeni varyasyon geldiğinde yeniden yazılmamasıdır.

Bir ödeme sisteminde sağlayıcı seçimi şöyle yapılabilir:

plaintext
class PaymentService:
    def pay(self, method, amount):
        if method == "card":
            return CardGateway().charge(amount)
        elif method == "bank":
            return BankGateway().transfer(amount)
        raise ValueError("Unsupported payment method")

Üçüncü ödeme yöntemi geldiğinde PaymentService değişir. Her yeni koşul, daha önce çalışan yolların bulunduğu aynı karar bloğuna eklenir.

Sorun if kullanılması değildir. Sorun, değişmesi beklenen sağlayıcı listesinin ödeme akışının içine gömülmesidir.

Bunun yerine ödeme davranışı ortak bir contract üzerinden tanımlanabilir.

plaintext
from typing import Protocol

class PaymentGateway(Protocol):
    def charge(self, amount: int) -> str:
        ...

class PaymentService:
    def __init__(self, gateway: PaymentGateway):
        self.gateway = gateway

    def pay(self, amount: int) -> str:
        return self.gateway.charge(amount)

Yeni sağlayıcı aynı contract'ı uyguladığında PaymentService değişmeden kalabilir. Sağlayıcının seçimi uygulamanın başlangıç konfigürasyonunda veya ayrı bir factory içinde yapılabilir.

Fakat bu tasarım önemli bir gerçeği değiştirmez: Sistemin bir yerinde yeni sağlayıcının kaydedilmesi gerekir. OCP, değişikliği ortadan kaldırmaz; değişikliğin yerini sınırlar.

Ayrıca her koşullu ifade OCP problemi değildir. Sabit ve küçük bir iş kuralında üç durum varsa, bunları polymorphism ile temsil etmek kodu gereksiz karmaşıklaştırabilir.

OCP özellikle aynı eksende tekrar tekrar genişleyen sistemlerde değerlidir. Ödeme sağlayıcıları, dosya formatları veya dış servis adapter'ları buna örnektir. Henüz değişmesi beklenmeyen bir davranış için baştan plugin mimarisi kurmak ise gelecekteki ihtimallere bugünden maliyet ödemektir.

Liskov Substitution: Aynı interface aynı davranış demek değildir

Liskov Substitution Principle (LSP), bir alt türün üst türün kullanıldığı yerde programın doğruluğunu bozmadan kullanılabilmesini gerektirir. Burada belirleyici unsur metod isimleri değil, davranışsal contract'tır.

Örneğin aşağıdaki kodda her ödeme sağlayıcısı aynı metodu sunar.

plaintext
class PaymentGateway:
    def charge(self, amount):
        raise NotImplementedError

class CardGateway(PaymentGateway):
    def charge(self, amount):
        return "payment-id-123"

class DeferredGateway(PaymentGateway):
    def charge(self, amount):
        return None

Kod çalışabilir. Ancak PaymentGateway contract'ı başarılı bir ödeme sonucunda takip edilebilir bir ödeme kimliği döndürmeyi gerektiriyorsa DeferredGateway bu contract'ı ihlal eder.

Çağıran kodun şu varsayımı artık güvenli değildir:

plaintext
payment_id = gateway.charge(amount)
repository.mark_paid(order_id, payment_id)

Daha doğru tasarım, ödeme durumunu açıkça modellemektir.

plaintext
from dataclasses import dataclass
from enum import Enum

class PaymentStatus(Enum):
    PAID = "paid"
    PENDING = "pending"

@dataclass(frozen=True)
class PaymentResult:
    status: PaymentStatus
    reference: str

class PaymentGateway(Protocol):
    def charge(self, amount: int) -> PaymentResult:
        ...

Artık hem anında tamamlanan hem beklemeye alınan işlemler aynı contract içinde temsil edilebilir. Çağıran kod PAID ile PENDING durumlarını açıkça ayırmak zorundadır.

Bu çözümün geçerli olması için bütün sağlayıcıların belirtilen contract'a gerçekten uyması gerekir. Başarı sonucunda PAID döndüren bir adapter, paranın kesinleşmediği durumda yanlış bilgi veriyorsa type uyumu LSP uyumu sağlamaz.

LSP, exception davranışlarını da kapsar. Alt tür daha güçlü önkoşullar dayatmamalı veya vaat edilen sonkoşulları zayıflatmamalıdır. Örneğin base contract pozitif tutarların tamamını kabul ederken bir implementasyon yalnızca belirli bir aralıktaki tutarları kabul ediyorsa, bu kısıt contract'ta tanımlanmadan güvenli substitution mümkün değildir.

Pratikte LSP için en değerli araçlardan biri contract test'leridir. Aynı test paketi bütün adapter implementasyonlarına uygulanır. Böylece ortak interface'in arkasındaki davranış farkları yalnızca production ortamında keşfedilmez.

Interface Segregation ve Dependency Inversion birlikte çalışır

Interface Segregation Principle (ISP), client'ların kullanmadıkları operasyonlara bağımlı olmamasını ister. Büyük bir interface, birbirinden bağımsız ihtiyaçları aynı contract'a bağladığında değişimin etkisini genişletir.

Bir raporlama servisinin yalnızca sipariş okuması gerektiğini düşünelim.

plaintext
class OrderRepository:
    def find(self, order_id):
        ...

    def save(self, order):
        ...

    def delete(self, order_id):
        ...

    def refund(self, order_id):
        ...

Raporlama bileşeni yalnızca find metodunu kullanır. Buna rağmen aynı interface üzerinden yazma, silme ve iade işlemlerinin tamamına bağımlı hale gelir.

Daha dar bir contract ihtiyacı doğru ifade eder.

plaintext
class OrderReader(Protocol):
    def find(self, order_id: str):
        ...

class ReportService:
    def __init__(self, orders: OrderReader):
        self.orders = orders

    def build(self, order_id):
        order = self.orders.find(order_id)
        return {"order_id": order_id, "total": order.total}

Buradaki kazanım sadece daha kısa interface değildir. ReportService, siparişlerin nasıl saklandığını veya hangi yazma operasyonlarının mevcut olduğunu bilmez. Test sırasında yalnızca find davranışı sağlayan küçük bir nesne yeterlidir.

Dependency Inversion Principle (DIP) bu ayrımı mimarinin bağımlılık yönüne taşır. Yüksek seviyeli iş politikaları, düşük seviyeli teknik ayrıntılara doğrudan bağımlı olmamalıdır. İkisi de uygun abstraction'lara bağımlı olmalıdır.

Kötü örnek, iş akışının doğrudan somut altyapı nesnesi oluşturmasıdır.

plaintext
class CheckoutService:
    def __init__(self):
        self.gateway = StripeGateway()

Bu tasarımda ödeme akışı belirli bir sağlayıcının API kararlarına bağlanır. Sağlayıcının değiştirilmesi veya test sırasında farklı davranış verilmesi zorlaşır.

DIP uygulandığında iş akışı kendi ihtiyacını ifade eden contract'a bağımlı olur.

plaintext
class CheckoutService:
    def __init__(self, gateway: PaymentGateway):
        self.gateway = gateway

    def checkout(self, amount: int) -> PaymentResult:
        return self.gateway.charge(amount)

Somut implementasyon uygulamanın composition root adı verilen kurulum noktasında bağlanır.

plaintext
checkout = CheckoutService(gateway=StripeGateway())

Burada StripeGateway nesnesinin PaymentGateway contract'ına uygun olduğu varsayılır. Bu bağlantı startup sırasında yapılır; iş kuralı sağlayıcıyı kendisi oluşturmaz.

DIP ile dependency injection aynı kavram değildir. Dependency injection bağımlılığın dışarıdan verilmesi tekniğidir. DIP ise iş politikasının hangi abstraction'a bağımlı olması gerektiğiyle ilgilenir.

Bir servis constructor'ına somut StripeGateway nesnesi geçirmek dependency injection olabilir, fakat iş kodu hâlâ Stripe'a özgü metotları kullanıyorsa DIP'in sağladığı ayrım gerçekleşmez.

Asıl problem interface sayısı değil, değişimin yayılmasıdır

SOLID hakkındaki yaygın ama eksik açıklama şudur: Class'ları küçült, interface ekle, inheritance kullan ve bağımlılıkları constructor üzerinden geçir. Bu reçete bazı kod tabanlarında işe yarar, ancak değişimin maliyetini belirleyen mekanizmayı açıklamaz.

Mekanizma bağımlılıkların yönü ve değişken kararların nerede toplandığıdır. Bir karar değiştiğinde onu bilen modüllerin düzenlenmesi gerekir. Bu bilgi çok sayıda modüle dağılmışsa değişiklik de yayılır.

Örneğin bir ödeme sağlayıcısının HTTP response formatını beş farklı servis biliyorsa, sağlayıcı API'sindeki değişiklik beş servisi etkiler. Araya bir interface koymak tek başına çözüm değildir. Beş servis aynı sağlayıcıya özgü response alanlarını kullanmaya devam ediyorsa bağımlılık yalnızca başka bir dosyaya taşınmıştır.

Gerçek ayrım, dış sistemin response formatını adapter içinde iş uygulamasının PaymentResult modeline dönüştürmektir. Böylece iş kodu sağlayıcının status_code, transaction_state veya başka özel alanlarını bilmez.

Bu noktada abstraction bir isimlendirme katmanı olmaktan çıkar; iki farklı değişim hızını birbirinden ayıran sınır haline gelir.

Bunun da maliyeti vardır. Her abstraction yeni bir contract, yeni test yükü ve hata ayıklarken izlenmesi gereken yeni bir çağrı oluşturur. Gereksiz abstraction sayısı arttığında basit bir değişiklik için daha fazla dosya düzenlenir.

Dolayısıyla SOLID'i değerlendirirken class sayısını değil, change coupling'i incelemek gerekir. Change coupling, iki bileşenin aynı değişiklik nedeniyle birlikte düzenlenme eğilimidir. Sürekli birlikte değişen iki küçük class, kâğıt üzerinde ayrılmış olsalar bile pratikte sıkı bağlı olabilir.

Benim tercih ettiğim ölçüt şudur: Yeni bir iş gereksinimi geldiğinde değişiklik hangi dosyalara yayılıyor ve mevcut davranışın ne kadarı yeniden test edilmek zorunda kalıyor? Bir abstraction bu yayılmayı azaltmıyorsa yalnızca SOLID'e benzediği için korunmayı hak etmez.

Gerçek bir checkout değişikliğinde sınırlar görünür olur

Bir backend geliştiricisinin önünde şu iş olduğunu düşünelim: Kart ödemesi alan checkout akışına banka transferi seçeneği eklenecek. Mevcut sistemde checkout.py dosyası sipariş toplamını hesaplıyor, sağlayıcının HTTP endpoint'ini çağırıyor, sonucu veritabanına yazıyor ve müşteriye bildirim gönderiyor.

İlk bakışta görev yalnızca yeni bir ödeme seçeneği eklemektir. Ancak banka transferi anında tamamlanmayabilir. Kart ödemesi başarılı olduğunda sipariş PAID durumuna geçerken transfer işlemi PENDING olarak kalabilir.

Bu fark mimarinin hangi soyutlamalara ihtiyaç duyduğunu belirler. Yalnızca yeni bir BankGateway class'ı yazmak yeterli değildir; checkout akışının ödeme tamamlanmasını nasıl yorumladığı da düzeltilmelidir.

Geliştirici önce mevcut çağrı noktalarını inceler. checkout.py, ödeme sonucunu boolean olarak kabul ediyorsa True ile False arasına üçüncü bir durum eklemek gerekir. Bu nedenle ödeme sonucunu PaymentResult ile temsil etmek, yeni bir interface eklemekten daha önemli olabilir.

Ardından kart sağlayıcısının response verisini uygulamanın ortak modeline dönüştüren adapter oluşturulur. Banka transferi adapter'ı da aynı contract'ı uygular. Ortak contract, işlemin referansını ve durumunu açıkça belirtir.

Sipariş durumunu güncelleyen kod yalnızca PAID sonucunda siparişi ödenmiş işaretler. PENDING sonucunda işlem referansını saklar ve sonraki bildirim veya doğrulama adımını bekler.

Burada kritik bir üretim detayı ortaya çıkar: Sağlayıcıya yapılan ödeme çağrısı başarılı olup veritabanına yazma işlemi başarısız olabilir. İşlem yeniden denendiğinde aynı ödemenin iki kez oluşturulmaması gerekir.

Bu nedenle ödeme akışı idempotency mekanizmasına ihtiyaç duyabilir. Idempotency, aynı mantıksal isteğin tekrar uygulanmasının ikinci bir ödeme gibi yeni bir etki oluşturmamasıdır. Uygun bir idempotency key'in sağlayıcıya iletilmesi ve siparişle ilişkilendirilmesi, class hiyerarşisinin nasıl çizildiğinden daha kritik olabilir.

Aynı şekilde callback veya webhook ile tamamlanan ödemelerde durum geçişleri açıkça modellenmelidir. PENDING durumundan PAID durumuna geçişin hangi olayla gerçekleşeceği, duplicate webhook'ların nasıl ele alınacağı ve başarısız olayların nasıl yeniden deneneceği contract'ın parçasıdır.

Bu değişiklik için code review sırasında yalnızca yeni interface'lere bakmak yeterli değildir. Ödeme sonucunun anlamı, transaction sınırları, tekrar deneme davranışı ve contract test'leri incelenmelidir.

İyi tasarımın somut sonucu şudur: Yeni sağlayıcı adapter'ı eklenirken fiyatlandırma ve sipariş yönetimi politikaları gereksiz yere değiştirilmez. Fakat iş gereksinimi gerçekten değişiyorsa, örneğin sistem ilk kez bekleyen ödemeleri destekleyecekse, ortak domain modelinin değişmesi normaldir. SOLID böyle bir değişikliği yasaklamaz.

SOLID'i yalnızca gerçek değişim sınırlarında uygula

SOLID'i uygulamak için önerim, mevcut kodu prensip isimlerine göre parçalamak yerine değişiklik geçmişini ve beklenen değişim noktalarını incelemektir. Son birkaç feature sırasında aynı dosyalar sürekli birlikte değişmiş mi? Yeni bir sağlayıcı eklemek mevcut iş kurallarını düzenlemeyi gerektirmiş mi? Testlerde somut altyapı bağımlılıkları yüzünden gereksiz kurulum yapılmış mı?

Bu soruların cevapları bir refactor için gerçek gerekçe üretir. Özellikle dış sistem adapter'ları, farklı ekiplerin yönettiği politikalar ve sık değişen entegrasyonlar açık sınırlar için güçlü adaylardır.

Buna karşılık küçük, tek amaçlı ve değişim ekseni henüz belirgin olmayan bir modülde doğrudan kod çoğu zaman daha iyi tercihtir. Tek implementasyonu olan her bağımlılık için interface üretmek, gelecekte ihtiyaç duyulmayacak genişleme noktalarını bugünden bakım yüküne dönüştürür.

Inheritance yerine composition kullanmak da otomatik olarak doğru tasarım sağlamaz. Composition bağımlılıkların birleştirilmesini kolaylaştırır, ancak yanlış tanımlanmış bir contract'ı düzeltmez. LSP ihlali, inheritance olmadan da ortak interface arkasındaki iki adapter arasında görülebilir.

Refactor kararında en azından şu karşılaştırmayı yaparım: Mevcut tasarımda beklenen bir değişiklik kaç bağımsız modüle dokunuyor; önerilen tasarımda kaç contract ve adapter korunmak zorunda kalacak? Kazanım yalnızca teorik bir geleceğe dayanıyorsa yeni abstraction'ı ertelemek daha güvenlidir.

Bu yaklaşımın sınırı da açık. Güvenlik, finansal doğruluk veya public API compatibility gibi alanlarda değişim henüz sık yaşanmamış olsa bile contract'ı baştan netleştirmek gerekebilir. Yanlış davranışın maliyeti yüksekse geçmişteki değişiklik sıklığı tek ölçüt olamaz.

SOLID'in beş prensibini bir mimari kontrol listesi olarak değil, değişimin nereden geçmesi gerektiğine ilişkin beş farklı sınama olarak kullanın. SRP değişim nedenlerini, OCP genişleme noktalarını, LSP davranışsal uyumu, ISP client ihtiyaçlarını, DIP ise bağımlılık yönünü sorgular. Yeni bir interface bu sınırlardan hiçbirini iyileştirmiyorsa eklemeyin; gerçek bir değişim maliyetini düşürüyorsa tasarımın parçası yapın.

yorumlar

0

Henüz yorum yok. İlk notu sen bırak.

Yorum yazmak için giriş yap.

Gmail ile saniyeler içinde, ya da e-posta ile kaydol.

bağlı 26109:32:58