İçeriğe geç

Java’da SOLID Prensipleri: Gerçek Kod Örnekleriyle 5 Temel İlke

Bir Java uygulamasının ilk sürümünü geliştirmek genellikle zor değildir. Asıl sorun, yeni özellikler geldikçe ortaya çıkar.

Örneğin bir e-ticaret uygulamasında sipariş oluşturma işlemini düşünelim. İlk sürümde kullanıcı sipariş verir, sistem ödemeyi alır ve e-posta gönderir. Bir süre sonra ürün ekibi yeni ödeme yöntemleri, farklı indirim kuralları, SMS bildirimleri ve kurumsal müşterilere özel işlemler ister.

java_solid_prensipleri
SOLID ilkelerinin Java tabanlı sipariş yönetimi uygulamasındaki sorumluluk ve bağımlılık ilişkileri.

Bu noktada geliştirici tek bir OrderService sınıfına sürekli yeni metotlar ekliyorsa basit bir değişiklik bile beklenmedik hatalara yol açabilir.

SOLID prensipleri tam olarak bu tür tasarım sorunlarıyla ilgilenir.

SOLID, nesne yönelimli yazılım tasarımında sorumlulukları ayırmayı, bağımlılıkları yönetmeyi ve değişikliklerin etkisini sınırlamayı hedefleyen beş temel prensibin baş harflerinden oluşur.

Bu makalede SOLID’in beş ilkesini gerçek Java örnekleriyle inceleyeceğiz. Her ilke için önce problemli yaklaşımı görecek, ardından kodu yeniden düzenleyeceğiz. Ayrıca Java 17 ve Java 21 özelliklerinin bu prensiplerle nasıl ilişkilendiğini ve gereksiz soyutlamalardan nasıl kaçınabileceğimizi ele alacağız.

SOLID Prensipleri Nedir?

SOLID kavramı, Robert C. Martin’in yaygınlaştırdığı beş nesne yönelimli tasarım prensibini bir araya getirir. Michael Feathers bu prensiplerin baş harflerini SOLID kısaltması altında topladı.

Bu ilkeler Java’ya özel değildir. C#, Kotlin, C++ ve nesne yönelimli başka dillerde de aynı tasarım problemleriyle karşılaşırız.

KısaltmaPrensipTemel düşünce
SSingle Responsibility PrincipleBir modülün değişmesi için tek bir temel neden olmalı
OOpen/Closed PrincipleMevcut davranışı değiştirmeden yeni davranışlar eklemeyi kolaylaştır
LLiskov Substitution PrincipleAlt türler, üst türlerin sözleşmesini korumalı
IInterface Segregation Principleİstemcileri ihtiyaç duymadıkları metotlara bağımlı bırakma
DDependency Inversion PrincipleÜst seviye iş kurallarını somut altyapı sınıflarına bağımlı kılma

Prensipleri birbirinden bağımsız kurallar gibi düşünmek doğru olmaz.

Örneğin Dependency Inversion uyguladığınızda çoğu zaman Interface Segregation ilkesiyle de karşılaşırsınız. Strategy tasarım desenini kullanırken Open/Closed Principle önem kazanır. Sınıfların sorumluluklarını ayırdığınızda ise test yazmak kolaylaşır.

Bununla birlikte SOLID’e uyan bir tasarımın otomatik olarak iyi tasarım olacağını söyleyemeyiz. Ekibin deneyimi, uygulamanın büyüklüğü ve iş gereksinimlerinin değişme sıklığı da mimari kararları etkiler.

SOLID Prensipleri Neden Önemlidir?

Yazılım geliştirme sürecinde en yüksek maliyet her zaman ilk kodu yazmak değildir. Mevcut kodun nasıl çalıştığını anlamak, bir değişikliğin etkisini belirlemek ve yeni davranışı güvenli biçimde eklemek de zaman alır.

SOLID bu maliyetleri azaltmaya yardımcı olur.

Değişikliklerin Etkisini Sınırlar

Bir ödeme yöntemi eklemek için stok, bildirim ve raporlama kodlarını değiştirmek zorunda kalmamalısınız.

Test Yazmayı Kolaylaştırır

Bir sınıf ihtiyaç duyduğu servisleri dışarıdan alıyorsa test sırasında gerçek veritabanı veya ödeme sistemi yerine kontrollü test nesneleri kullanabilirsiniz.

Bağımlılıkları Daha Açık Hâle Getirir

Constructor üzerinden alınan bağımlılıklar, sınıfın hangi bileşenlere ihtiyaç duyduğunu doğrudan gösterir.

Kodun Okunabilirliğini Artırabilir

Her sınıf belirli bir sorumluluğa odaklandığında geliştirici davranışın nerede bulunduğunu daha kolay bulur.

Ancak aşırı soyutlama tam tersine etki edebilir. Her işlem için ayrı interface, factory, manager ve helper sınıfları oluşturmak kodun anlaşılmasını zorlaştırabilir.

Önemli olan sınıf sayısını artırmak değil, değişikliklerin maliyetini düşürmektir.

1. Single Responsibility Principle (SRP)

Single Responsibility Principle, Türkçede Tek Sorumluluk Prensibi olarak bilinir.

Bu ilkeyi genellikle “Bir sınıf yalnızca tek bir iş yapmalı” şeklinde duyarız. Ancak bu açıklama eksik kalır.

SRP’nin daha anlamlı yorumu şudur:

Bir modülün değişmesi için tek bir temel neden bulunmalıdır.

Buradaki değişiklik nedenini, modülün hizmet ettiği iş sorumluluğu veya ilgili paydaşın beklentisi üzerinden değerlendirebiliriz.

Bir sınıfta birden fazla metot bulunması tek başına SRP ihlali değildir.

SRP İhlali: Her Şeyi Yöneten OrderService

Bir sipariş servisi yazdığımızı düşünelim.

import java.math.BigDecimal;

public class OrderService {

    public void createOrder(String customerEmail,
                            BigDecimal amount) {

        // Sipariş kaydını oluştur.
        System.out.println("Order created");

        // Ödemeyi al.
        System.out.println("Payment: " + amount);

        // E-posta gönder.
        System.out.println("Email: " + customerEmail);

        // Rapor oluştur.
        System.out.println("Report updated");
    }
}

Bu örnekte OrderService dört farklı sorumluluk taşıyor.

Sipariş yönetimi ekibi sipariş kurallarını değiştirebilir. Finans ekibi ödeme akışına yeni kontroller ekleyebilir. Bildirim ekibi SMS desteği isteyebilir. Raporlama ekibi ise yeni bir analitik servisine geçebilir.

Bütün bu değişiklikler aynı sınıfı etkiliyor.

Kod başlangıçta küçük görünse bile gerçek servislerle entegrasyon kurduğumuzda sınıf hızla büyür.

SRP’ye Uygun Tasarım

Önce sipariş verisini tanımlayalım.

import java.math.BigDecimal;
import java.util.Objects;

public record Order(
        String id,
        String customerEmail,
        BigDecimal amount) {

    public Order {
        Objects.requireNonNull(id);
        Objects.requireNonNull(customerEmail);
        Objects.requireNonNull(amount);

        if (amount.signum() < 0) {
            throw new IllegalArgumentException(
                    "Sipariş tutarı negatif olamaz"
            );
        }
    }
}

Java 17 ile kullanabileceğimiz record, veri modelinin tekrar eden kodunu azaltır.

Ardından sorumlulukları ayıralım.

interface OrderRepository {
    void save(Order order);
}

interface PaymentProcessor {
    void charge(Order order);
}

interface OrderNotifier {
    void notifyCustomer(Order order);
}

Şimdi iş akışını koordine eden servisi oluşturalım.

public final class OrderService {

    private final OrderRepository repository;
    private final PaymentProcessor paymentProcessor;
    private final OrderNotifier notifier;

    public OrderService(
            OrderRepository repository,
            PaymentProcessor paymentProcessor,
            OrderNotifier notifier) {

        this.repository = repository;
        this.paymentProcessor = paymentProcessor;
        this.notifier = notifier;
    }

    public void createOrder(Order order) {
        repository.save(order);
        paymentProcessor.charge(order);
        notifier.notifyCustomer(order);
    }
}

OrderService artık sipariş oluşturma akışını koordine eder. Veritabanına nasıl yazacağımızı, ödemeyi hangi sağlayıcıyla alacağımızı veya müşteriye hangi kanaldan ulaşacağımızı bilmez.

Burada önemli bir ayrıntı var: OrderService üç ayrı bileşeni çağırdığı için SRP’yi ihlal etmiş sayılmaz. Servisin temel sorumluluğu sipariş oluşturma sürecini koordine etmektir.

Gerçek Projede Neye Dikkat Etmeliyiz?

Bu kod sorumluluk ayrımını gösterir, ancak üretim ortamına hazır bir sipariş akışı değildir.

Örneğin veritabanına sipariş kaydettikten sonra ödeme başarısız olursa ne olacak? Ödeme başarılı olurken bildirim servisi hata verirse işlemi geri alacak mıyız?

Gerçek projede sipariş durumlarını, ödeme idempotency anahtarlarını ve hata sonrası telafi işlemlerini ayrıca tasarlamalıyız.

SRP bu kararların yerine geçmez. Yalnızca ilgili sorumlulukları doğru bileşenlerde tutmamıza yardımcı olur.

SRP ne zaman önem kazanır? Bir sınıfın farklı iş ekiplerinden gelen değişiklikler nedeniyle sık sık güncelleme gerektirdiği durumlarda SRP’yi yeniden değerlendirin.

2. Open/Closed Principle (OCP)

Open/Closed Principle, Açık/Kapalı Prensibi olarak bilinir.

Bertrand Meyer’in ortaya koyduğu yaklaşım, yazılım bileşenlerinin genişlemeye açık, mevcut davranışı değiştirmeye ise mümkün olduğunca kapalı olmasını hedefler.

Buradaki amaç hiçbir zaman mevcut kodu değiştirmemek değildir.

Amaç, yeni bir davranış eklerken mevcut ve doğruluğunu testlerle desteklediğimiz kod üzerinde gereksiz değişiklik yapmamaktır.

OCP İhlali: İndirim Hesaplama

E-ticaret uygulamamızda standart, premium ve kurumsal müşteriler için farklı indirim oranları bulunsun.

import java.math.BigDecimal;

public class DiscountService {

    public BigDecimal calculate(
            BigDecimal amount,
            String customerType) {

        if ("STANDARD".equals(customerType)) {
            return BigDecimal.ZERO;
        }

        if ("PREMIUM".equals(customerType)) {
            return amount.multiply(
                    new BigDecimal("0.10")
            );
        }

        if ("CORPORATE".equals(customerType)) {
            return amount.multiply(
                    new BigDecimal("0.20")
            );
        }

        throw new IllegalArgumentException(
                "Unknown customer type"
        );
    }
}

Yeni bir müşteri tipi eklediğimizde DiscountService kodunu değiştirmek zorundayız.

Üstelik her yeni koşul, mevcut davranışları yanlışlıkla etkileme ihtimalini artırır.

OCP Çözümü: Strategy Pattern

İndirim algoritmasını bir arayüzle temsil edelim.

import java.math.BigDecimal;

@FunctionalInterface
public interface DiscountPolicy {

    BigDecimal calculate(BigDecimal amount);
}

Premium indirim politikası:

public final class PremiumDiscount
        implements DiscountPolicy {

    private static final BigDecimal RATE =
            new BigDecimal("0.10");

    @Override
    public BigDecimal calculate(BigDecimal amount) {
        return amount.multiply(RATE);
    }
}

Kurumsal müşteri politikası:

public final class CorporateDiscount
        implements DiscountPolicy {

    private static final BigDecimal RATE =
            new BigDecimal("0.20");

    @Override
    public BigDecimal calculate(BigDecimal amount) {
        return amount.multiply(RATE);
    }
}

Şimdi indirim servisini düzenleyelim.

import java.math.BigDecimal;
import java.util.Objects;

public final class DiscountService {

    private final DiscountPolicy policy;

    public DiscountService(DiscountPolicy policy) {
        this.policy = Objects.requireNonNull(policy);
    }

    public BigDecimal calculateDiscount(
            BigDecimal amount) {

        Objects.requireNonNull(amount);

        if (amount.signum() < 0) {
            throw new IllegalArgumentException(
                    "Tutar negatif olamaz"
            );
        }

        BigDecimal discount = policy.calculate(amount);

        if (discount == null
                || discount.signum() < 0
                || discount.compareTo(amount) > 0) {

            throw new IllegalArgumentException(
                    "Geçersiz indirim tutarı"
            );
        }

        return discount;
    }
}

Kullanım:

DiscountService service =
        new DiscountService(
                new PremiumDiscount()
        );

BigDecimal discount = service.calculateDiscount(
        new BigDecimal("1000.00")
);

System.out.println(discount); // 100.0000

Artık yeni bir kampanya için farklı bir DiscountPolicy uygulaması yazabiliriz.

DiscountService ise aynı sözleşme üzerinden çalışmaya devam eder.

Java Lambda ile Daha Kısa Bir Çözüm

DiscountPolicy fonksiyonel bir arayüz olduğu için Java lambda ifadelerini kullanabiliriz.

DiscountPolicy seasonalDiscount =
        amount -> amount.multiply(
                new BigDecimal("0.15")
        );

DiscountService service =
        new DiscountService(seasonalDiscount);

Bu yaklaşım küçük, durum tutmayan hesaplamalar için yeterli olabilir.

Ancak kampanya yönetimi farklı veritabanı sorguları, koşullar veya harici servisler gerektiriyorsa ayrı bir sınıf daha anlamlıdır.

OCP’nin Sınırları

Strategy kullanmak yeni bir algoritmanın sistem tarafından otomatik olarak keşfedileceği anlamına gelmez.

Örneğin stratejileri seçen bir factory veya registry varsa yeni davranışı oraya tanıtmanız gerekebilir.

Ayrıca her yeni iş kuralı için interface oluşturmak doğru değildir. Değişim ihtiyacını destekleyen gerçek bir nedeniniz olmalı.

OCP ne zaman önem kazanır? Yeni varyasyonlar aynı kod bloğunda sürekli yeni koşullar oluşturuyorsa genişletilebilir bir davranış modeli değerlendirin.

3. Liskov Substitution Principle (LSP)

Liskov Substitution Principle, Barbara Liskov’un alt türler ve davranışsal sözleşmeler üzerine geliştirdiği yaklaşıma dayanır.

İlkenin temel düşüncesi şudur:

Bir alt tür, üst türün beklentilerini bozmadan onun yerine kullanılabilmelidir.

Burada yalnızca Java derleyicisinin tür uyumluluğunu kontrol etmesi yeterli değildir.

Metotların ön koşulları, son koşulları, hata davranışları ve nesnenin koruması gereken kurallar da önem taşır.

LSP İhlali: Bütün Ödemeler İptal Edilebilir mi?

Bir ödeme sisteminin hem kredi kartı ödemelerini hem de belirli hediye kartı ödemelerini desteklediğini düşünelim.

İlk tasarımda bütün ödeme yöntemleri için aynı arayüzü kullanalım.

import java.math.BigDecimal;

interface PaymentMethod {

    void pay(BigDecimal amount);

    void refund(BigDecimal amount);
}

Kredi kartı uygulaması:

class CreditCardPayment implements PaymentMethod {

    @Override
    public void pay(BigDecimal amount) {
        System.out.println("Card payment");
    }

    @Override
    public void refund(BigDecimal amount) {
        System.out.println("Card refund");
    }
}

Belirli bir hediye kartı ürününün iadeyi desteklemediğini varsayalım.

class GiftCardPayment implements PaymentMethod {

    @Override
    public void pay(BigDecimal amount) {
        System.out.println("Gift card payment");
    }

    @Override
    public void refund(BigDecimal amount) {
        throw new UnsupportedOperationException(
                "Refund is not supported"
        );
    }
}

GiftCardPayment, Java tür sistemi açısından geçerli bir PaymentMethod uygulamasıdır.

Ancak arayüzün sözleşmesi bütün ödeme yöntemlerinin iade yapabildiğini vaat ediyorsa bu sınıf beklentiyi karşılamaz.

İstemci güvenle refund() çağıracağını düşünürken çalışma zamanında hata alır.

Bu örnekte hata, somut türün üst tür sözleşmesini yerine getirememesidir.

LSP’ye Uygun Arayüz Tasarımı

Bütün ödeme yöntemlerinin ödeme yapabildiğini, ancak yalnızca belirli yöntemlerin iade desteklediğini açık biçimde modelleyelim.

import java.math.BigDecimal;

interface PaymentMethod {
    void pay(BigDecimal amount);
}

interface RefundablePayment extends PaymentMethod {
    void refund(BigDecimal amount);
}

Kredi kartı:

final class CreditCardPayment
        implements RefundablePayment {

    @Override
    public void pay(BigDecimal amount) {
        System.out.println("Card payment");
    }

    @Override
    public void refund(BigDecimal amount) {
        System.out.println("Card refund");
    }
}

Hediye kartı:

final class GiftCardPayment
        implements PaymentMethod {

    @Override
    public void pay(BigDecimal amount) {
        System.out.println("Gift card payment");
    }
}

İade servisi yalnızca iade yeteneğine sahip ödeme türleriyle çalışsın.

import java.math.BigDecimal;
import java.util.Objects;

final class RefundService {

    private final RefundablePayment payment;

    RefundService(RefundablePayment payment) {
        this.payment = Objects.requireNonNull(payment);
    }

    void refund(BigDecimal amount) {
        payment.refund(amount);
    }
}

Bu tasarım, bütün ödeme yöntemlerine sahip olmadıkları bir davranışı zorla eklemez.

Ayrıca derleyici, iade yeteneği olmayan bir PaymentMethod nesnesini doğrudan RefundService constructor’ına vermemize izin vermez.

LSP Yalnızca Exception Meselesi Değildir

Bir alt tür metot imzasını koruduğu hâlde farklı semantik davranış gösterebilir.

Örneğin:

  • Üst tür negatif olmayan tutarlar kabul ederken alt tür yalnızca belirli bir tutarın üzerindeki işlemleri kabul edebilir.
  • Üst tür başarılı işlem sonrasında kesin kayıt oluşturacağını vaat ederken alt tür sessizce işlemi atlayabilir.
  • Üst tür belirli bir hata sözleşmesi sunarken alt tür ilgisiz bir istisna fırlatabilir.

Bu durumlarda Java’nın derleme zamanı kontrolleri her davranışsal uyumsuzluğu yakalayamaz.

Bu yüzden LSP’yi test ederken yalnızca metotların varlığını değil, sözleşmenin korunduğunu da doğrulamalıyız.

LSP ne zaman önem kazanır? Kalıtım veya arayüz polimorfizmi kullanan kodlarda somut uygulamaların istemci beklentilerini değiştirdiğini fark ediyorsanız LSP’yi inceleyin.

4. Interface Segregation Principle (ISP)

Interface Segregation Principle, Arayüz Ayrımı Prensibi olarak bilinir.

Bu ilke, istemcilerin ihtiyaç duymadıkları metotlara bağımlı hâle gelmemesini önerir.

Bir arayüz çok sayıda sorumluluk içerdiğinde onu uygulayan sınıflar gereksiz metotlar taşımak zorunda kalabilir.

ISP İhlali: Büyük Bir Raporlama Arayüzü

E-ticaret uygulamamız için rapor hazırlayan bir servis geliştirdiğimizi düşünelim.

interface ReportService {

    void generatePdf();

    void generateExcel();

    void sendEmail();

    void uploadToCloud();
}

Bir PDF raporlama servisi yalnızca PDF üretmek isteyebilir. Ancak arayüz bütün metotları uygulamasını zorunlu kılar.

class PdfReportService implements ReportService {

    @Override
    public void generatePdf() {
        System.out.println("PDF generated");
    }

    @Override
    public void generateExcel() {
        throw new UnsupportedOperationException();
    }

    @Override
    public void sendEmail() {
        throw new UnsupportedOperationException();
    }

    @Override
    public void uploadToCloud() {
        throw new UnsupportedOperationException();
    }
}

Bu tasarımda arayüz farklı yetenekleri gereksiz biçimde tek sözleşmede toplar.

ISP Çözümü: Küçük ve Odaklı Arayüzler

Rapor üretme işlemini veri formatı üzerinden ayıralım.

interface PdfGenerator {
    byte[] generatePdf();
}

interface ExcelGenerator {
    byte[] generateExcel();
}

Dağıtım yeteneklerini de ayrı tanımlayalım.

interface EmailSender {
    void send(String recipient, byte[] content);
}

interface CloudUploader {
    void upload(String key, byte[] content);
}

Artık PDF üreten bir servis yalnızca ihtiyacı olan sözleşmeyi uygulayabilir.

import java.nio.charset.StandardCharsets;

final class PdfReportGenerator implements PdfGenerator {

    @Override
    public byte[] generatePdf() {
        // Örnek veri: gerçek PDF üretimi değildir.
        return "PDF example"
                .getBytes(StandardCharsets.UTF_8);
    }
}

PDF oluşturma ve e-posta gönderme işlemlerini birleştiren servis ise her iki bağımlılığı dışarıdan alabilir.

import java.util.Objects;

final class ReportDeliveryService {

    private final PdfGenerator generator;
    private final EmailSender emailSender;

    ReportDeliveryService(
            PdfGenerator generator,
            EmailSender emailSender) {

        this.generator = Objects.requireNonNull(generator);
        this.emailSender = Objects.requireNonNull(emailSender);
    }

    void deliver(String recipient) {
        byte[] report = generator.generatePdf();
        emailSender.send(recipient, report);
    }
}

Bu tasarımda PDF üreticisini Excel üretmeye zorlamıyoruz. E-posta gönderme yeteneğini de rapor oluşturma sorumluluğuyla karıştırmıyoruz.

Her Metot İçin Ayrı Arayüz Açmalı mıyız?

Hayır.

ISP’nin amacı arayüzleri olabildiğince küçültmek değildir.

Birbiriyle anlamlı ilişki taşıyan metotlar aynı sözleşmede bulunabilir. Örneğin dosya okuma işlemlerini ele alan bir arayüzün read() ve close() davranışlarını birlikte sunması makul olabilir.

Asıl soru, arayüzü kullanan istemcilerin hangi yeteneklere ihtiyaç duyduğudur.

ISP ne zaman önem kazanır? Bir sınıf arayüz metotlarının büyük kısmına ihtiyaç duymuyorsa veya sık sık UnsupportedOperationException yazıyorsanız arayüzü yeniden tasarlayın.

5. Dependency Inversion Principle (DIP)

Dependency Inversion Principle, Bağımlılıkların Tersine Çevrilmesi Prensibi olarak bilinir.

Bu ilkeye göre üst seviye iş kuralları, alt seviye altyapı ayrıntılarına doğrudan bağımlı olmamalıdır.

Her iki taraf da uygun soyutlamalara dayanmalıdır.

Ayrıca soyutlamalar altyapı ayrıntılarına değil, ayrıntılar soyutlamaların sunduğu sözleşmelere uyum sağlamalıdır.

DIP İhlali: OrderService ve Veritabanı Bağımlılığı

Bir sipariş servisi doğrudan Oracle veritabanına erişen sınıfı oluşturuyor olsun.

class OracleOrderRepository {

    void save(Order order) {
        System.out.println("Saving to Oracle");
    }
}

Sipariş servisi:

class OrderService {

    private final OracleOrderRepository repository =
            new OracleOrderRepository();

    void createOrder(Order order) {
        repository.save(order);
    }
}

Bu yaklaşımda üst seviye iş akışı doğrudan belirli bir veritabanı bileşenine bağımlıdır.

Test ortamında farklı bir repository kullanmak istediğimizde OrderService koduna müdahale etmemiz gerekebilir.

Ayrıca bu somut bağımlılık, sipariş iş kuralları ile kalıcılık teknolojisi arasında gereksiz bağ oluşturur.

DIP Çözümü: Repository Soyutlaması

Sipariş yönetiminin ihtiyaç duyduğu sözleşmeyi tanımlayalım.

interface OrderRepository {

    void save(Order order);
}

Oracle adaptörü:

final class OracleOrderRepository
        implements OrderRepository {

    @Override
    public void save(Order order) {
        System.out.println(
                "Saving order to Oracle: " + order.id()
        );
    }
}

Sipariş servisi:

import java.util.Objects;

final class OrderService {

    private final OrderRepository repository;

    OrderService(OrderRepository repository) {
        this.repository = Objects.requireNonNull(repository);
    }

    void createOrder(Order order) {
        repository.save(order);
    }
}

Artık bağımlılığı uygulamayı başlatan kod üzerinden verebiliriz.

OrderRepository repository =
        new OracleOrderRepository();

OrderService service =
        new OrderService(repository);

Bu örnekte OrderService, Oracle’a özgü bir sınıfa bağımlı değildir.

Veritabanı erişim teknolojisini değiştirmek istediğimizde aynı OrderRepository sözleşmesine uygun başka bir implementasyon geliştirebiliriz.

DIP ile Dependency Injection Aynı Şey mi?

Hayır.

DIP bir tasarım prensibidir.

Dependency Injection ise nesnenin ihtiyaç duyduğu bağımlılıkları dışarıdan verme tekniğidir.

Constructor Injection kullanmak, DIP’yi uygulamayı kolaylaştırır. Ancak her constructor parametresi otomatik olarak doğru soyutlama anlamına gelmez.

Örneğin OrderService constructor üzerinden doğrudan OracleOrderRepository alıyorsa bağımlılığı dışarıdan veriyor olsak bile iş katmanını somut altyapı türüne bağlamaya devam ederiz.

Spring Boot ile DIP

Spring Framework, constructor injection yaklaşımını destekler.

@Service
public class OrderService {

    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void createOrder(Order order) {
        repository.save(order);
    }
}

Bu örnekte @Service, Spring Framework’e ait bir annotation’dır. Projenin ilgili Spring bağımlılıklarını eklemesi gerekir.

Spring, uygun OrderRepository bean’ini uygulama bağlamından çözebilir. Birden fazla uygun bean bulunduğunda ise seçim için ek yapılandırma gerekebilir.

DIP’nin temel değeri Spring’in otomatik nesne oluşturması değildir. İş kurallarının altyapı ayrıntılarından bağımsız çalışabilmesidir.

DIP ne zaman önem kazanır? İş mantığınız doğrudan veritabanı sürücüsüne, HTTP istemcisine, mesaj aracısına veya üçüncü taraf SDK’ya bağımlıysa DIP’yi değerlendirin.

SOLID ve Java 17/21 Özellikleri

SOLID prensipleri Java’nın son sürümleriyle ortaya çıkmadı. Buna rağmen modern dil özellikleri bu prensipleri uygularken daha az kod yazmamıza yardımcı olabilir.

Record ile Veri Modellerini Sadeleştirmek

Java 17’de record, DTO ve değer taşıyan modeller için kullanışlıdır.

import java.math.BigDecimal;
import java.util.Objects;

public record Product(
        String id,
        String name,
        BigDecimal price) {

    public Product {
        Objects.requireNonNull(id);
        Objects.requireNonNull(name);
        Objects.requireNonNull(price);

        if (price.signum() < 0) {
            throw new IllegalArgumentException(
                    "Ürün fiyatı negatif olamaz"
            );
        }
    }
}

Bu örnekte derleyici constructor, accessor, equals(), hashCode() ve toString() gibi temel davranışları yönetir.

Record kullanmak tek başına SRP garantisi vermez. Ancak veri taşıma sorumluluğu bulunan modellerde gereksiz altyapı kodunu azaltır.

Sealed Interface ile Kapalı Tür Aileleri

Java 17 ile kalıcı hâle gelen sealed türleri, bir arayüzü hangi sınıfların uygulayabileceğini sınırlar.

sealed interface PaymentResult
        permits PaymentSuccess, PaymentFailure {
}

record PaymentSuccess(String transactionId)
        implements PaymentResult {
}

record PaymentFailure(String reason)
        implements PaymentResult {
}

Bu model, ödeme sonucunun iki temel varyasyonunu açıkça tanımlar.

Java 21 ile pattern matching for switch özelliğini kullanarak sonuçları işleyebiliriz.

final class PaymentResultHandler {

    static String describe(PaymentResult result) {

        return switch (result) {
            case PaymentSuccess success ->
                    "Payment OK: " + success.transactionId();

            case PaymentFailure failure ->
                    "Payment failed: " + failure.reason();
        };
    }
}

Derleyici kapalı tür ailesinin bütün üyelerini ele alıp almadığımızı kontrol edebilir.

Burada OCP açısından önemli bir tasarım tercihi bulunur.

Kapalı bir tür ailesi tanımladığımızda yeni bir ödeme sonucu türü eklemek mevcut switch ifadelerinin güncellenmesini gerektirebilir.

Dolayısıyla sealed interface, her durumda yeni davranışlara sınırsız genişletilebilirlik sunmaz.

Bu özellik, özellikle tür ailesini bilinçli olarak sınırlamak istediğimiz durumlarda değer kazanır.

Java 21 Her SOLID Problemini Çözmez

Modern Java, bazı teknik ayrıntıları azaltır. Ancak hangi sorumluluğun hangi bileşende bulunması gerektiğine derleyici karar vermez.

Record, yanlış sınıf sorumluluklarını düzeltmez.

Sealed interface, hatalı bir domain sözleşmesini kendiliğinden iyileştirmez.

Pattern matching ise bağımlılık yönünü otomatik olarak doğru hâle getirmez.

Dil özelliklerini mimari tasarımın yerine değil, tasarım kararlarını uygularken kullanmalıyız.

SOLID ile Tasarım Desenleri Arasındaki İlişki

SOLID, tasarım prensiplerini açıklar. Tasarım desenleri ise belirli problemler için çözüm yapıları sunar.

Bu iki kavramı aynı şey gibi değerlendirmek doğru olmaz.

SOLID ilkesiİlişkili tasarım yaklaşımlarıBağlantı
SRPFacade, DecoratorSorumlulukların ayrılmasına yardımcı olabilir
OCPStrategy, Factory MethodYeni davranışların genişletilmesini kolaylaştırır
LSPStrategy, Template MethodAlt türlerin davranış sözleşmeleri önem kazanır
ISPAdapter, küçük arayüzlerİstemci bağımlılıklarını daraltır
DIPStrategy, Repository, Dependency InjectionSoyutlamalar üzerinden bağımlılık kurar

Örneğin Strategy, OCP ve DIP açısından yarar sağlayabilir. Ancak kötü tasarladığımız bir Strategy arayüzü LSP’yi ihlal edebilir.

Bu nedenle bir tasarım desenini uygulamak tek başına SOLID’e uygun kod yazdığımız anlamına gelmez.

Java tasarım desenlerinin kategorilerini ve kullanım alanlarını daha geniş bir bakışla incelemek için serimizin ilk yazısı olan Java Tasarım Desenleri: Gerçek Hayat Örnekleri ve Modern Java rehberine göz atabilirsiniz.

SOLID Prensiplerini Uygularken Yapılan Hatalar

SOLID hakkında sık karşılaştığım bir sorun, geliştiricilerin prensipleri uygulamanın amacı yerine koymasıdır.

Kodun esnek olması önemlidir. Fakat hiçbir zaman ihtiyaç duymayacağımız varyasyonlar için büyük bir soyutlama ağı oluşturmak geliştirme maliyetini artırır.

Her Sınıf İçin Interface Oluşturmak

Bir sınıfın tek bir implementasyonu bulunuyorsa otomatik olarak interface açmak zorunda değilsiniz.

Interface, anlamlı bir sözleşme veya bağımlılık sınırı oluşturduğunda değer kazanır.

SRP’yi Tek Metot Kuralına Dönüştürmek

SRP, bir sınıfın yalnızca bir metoda sahip olmasını istemez.

Bir sipariş hesaplama bileşeni birden fazla ilişkili metot içerebilir. Önemli olan bu metotların aynı sorumluluk altında bulunmasıdır.

OCP’yi Hiç Kod Değiştirmemek Olarak Yorumlamak

Gerçek iş gereksinimleri değiştiğinde mevcut kodu güncellememiz normaldir.

OCP, bütün gelecekteki değişiklikleri önceden tahmin etmemizi beklemez.

Kalıtımı Her Durumda Tercih Etmek

Kalıtım güçlü bir mekanizmadır. Ancak bir sınıfın başka bir sınıftan davranış devralması her zaman doğru bir alt tür ilişkisi oluşturmaz.

Bazı durumlarda composition daha esnek bir çözüm sunar.

DIP’yi Yalnızca Spring Annotation’larına İndirgemek

@Autowired veya constructor injection kullanmak tek başına doğru mimari oluşturmaz.

İş kurallarının hangi katmanda bulunduğunu, hangi soyutlamaya bağımlı olduğunu ve bağımlılıkların hangi yönde ilerlediğini de incelemeliyiz.

SOLID Kodlarının Test Edilebilirliği

SOLID’in somut yararlarından biri, sınıfları gerçek altyapı servislerinden bağımsız test edebilmemizdir.

Örneğin DIP bölümündeki OrderService için gerçek Oracle bağlantısı kurmamıza gerek yok.

JUnit 5 ile küçük bir test yazabiliriz.

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

class OrderServiceTest {

    @Test
    void shouldSaveOrder() {

        class FakeOrderRepository
                implements OrderRepository {

            Order savedOrder;

            @Override
            public void save(Order order) {
                savedOrder = order;
            }
        }

        FakeOrderRepository repository =
                new FakeOrderRepository();

        OrderService service =
                new OrderService(repository);

        Order order = new Order(
                "ORD-1001",
                "[email protected]",
                new BigDecimal("250.00")
        );

        service.createOrder(order);

        assertEquals(order, repository.savedOrder);
    }
}

Bu testte veritabanı bağlantısı açmıyoruz.

Test, sipariş servisinin ilgili repository sözleşmesini doğru kullanıp kullanmadığını kontrol ediyor.

Elbette bu test Oracle veritabanına gerçekten kayıt yazabileceğimizi kanıtlamaz. Bunu doğrulamak için ayrıca entegrasyon testi geliştirmeliyiz.

Unit test ve integration test farklı riskleri ele alır.

SOLID burada yalnızca test yazmayı kolaylaştırır. Test stratejisini ve kapsamını yine geliştirici belirler.

SOLID Prensiplerini Uygulamak İçin Pratik Yaklaşım

Mevcut bir Java projesinde SOLID uygulamak istiyorsanız bütün sınıfları aynı anda yeniden tasarlamanızı önermem.

Bunun yerine küçük ve gözlemlenebilir adımlarla ilerleyin.

  1. Önce değişiklik maliyeti yüksek sınıfları belirleyin.
  2. Sınıfların neden değiştiğini ve hangi iş sorumluluklarını taşıdığını inceleyin.
  3. Mevcut davranışı koruyan karakterizasyon testleri yazın.
  4. Birden fazla sorumluluğu karıştıran kodları anlamlı bileşenlere ayırın.
  5. Somut altyapı bağımlılıklarını değerlendirin.
  6. Yeni davranışları eklerken değişiklik yapılan dosya sayısını ve kod karmaşıklığını gözlemleyin.
  7. Gereksiz soyutlamaları kaldırmaktan çekinmeyin.

Özellikle eski kurumsal Java uygulamalarında büyük bir refactoring çalışmasını tek seferde yapmak ciddi regresyon riski doğurabilir.

Her değişikliği küçük adımlarla ilerletmek, testlerle doğrulamak ve sistem davranışını izlemek daha güvenli bir yaklaşım sunar.

Sık Sorulan Sorular

SOLID prensipleri sadece Java için mi geçerlidir?

Hayır. SOLID, nesne yönelimli yazılım tasarımına yönelik genel prensiplerden oluşur. Java dışında C#, Kotlin ve C++ gibi dillerde de kullanabilirsiniz.

SOLID prensiplerini hangi sırayla öğrenmeliyim?

SRP ile başlayabilir, ardından OCP ve LSP konularına geçebilirsiniz. ISP ve DIP ise arayüzler ve bağımlılık yönetimi konusunda daha derin düşünmenizi sağlar. Ancak prensipler birbirini tamamladığı için gerçek projelerde tek tek değil, birlikte değerlendirmek gerekir.

SOLID ile Clean Code aynı şey mi?

Hayır. Clean Code okunabilirlik, isimlendirme, kod organizasyonu ve bakım kolaylığı gibi geniş bir alanı kapsar. SOLID ise nesne yönelimli tasarım prensiplerine odaklanır.

SOLID ile Design Patterns arasındaki fark nedir?

SOLID, iyi tasarım için yol gösteren prensipler sunar. Design Patterns ise tekrar eden yazılım tasarım problemleri için çözüm modelleri önerir.

Java Record, SOLID prensiplerinin yerini alır mı?

Hayır. Record, veri odaklı sınıfları daha kısa yazmamıza yardımcı olur. Sorumluluk ayrımı ve bağımlılık yönetimi gibi mimari kararların yerini alamaz.

Spring Boot kullanırken SOLID bilmek gerekir mi?

Evet. Spring Boot, Dependency Injection ve farklı soyutlama mekanizmaları sunar. Ancak bu mekanizmaları hangi amaçla ve hangi mimari sınırlar içinde kullanacağımıza geliştirici karar verir.

SOLID her projede uygulanmalı mı?

SOLID prensiplerini değerlendirmek yararlıdır; ancak her sınıfa aynı soyutlama düzeyini uygulamak doğru değildir. Küçük bir uygulamanın gereksinimleriyle geniş bir kurumsal sistemin gereksinimleri farklıdır.

Sonuç: SOLID, Daha Fazla Sınıf Yazmak Demek Değildir

Java’da SOLID prensiplerini uygulamanın amacı mümkün olduğunca fazla interface veya sınıf oluşturmak değildir.

SRP, sorumlulukların neden değiştiğini anlamamıza yardımcı olur. OCP, yeni davranışları mevcut kod üzerinde gereksiz değişiklik yapmadan eklemeyi hedefler. LSP, alt türlerin davranışsal sözleşmeleri korumasını ister. ISP, arayüzleri istemcilerin gerçek ihtiyaçlarına göre şekillendirir. DIP ise iş kurallarını altyapı ayrıntılarından bağımsız tutmaya odaklanır.

Beş prensibin ortak hedefi, değişiklik maliyetini yönetilebilir seviyede tutmaktır.

Bir tasarım kararı verirken önce gerçek problemi anlamalıyız. Ardından bu problemi çözecek en basit ve sürdürülebilir yapıyı seçebiliriz.

Bazı durumlarda bir arayüz ve iki sınıf doğru çözümdür. Başka bir durumda tek bir record ve kısa bir metot aynı işi daha açık biçimde yapabilir.

İyi Java mimarisi, her tasarım prensibini görünür biçimde kullanmakla değil, hangi durumda hangi soyutlamanın değer kazandırdığını bilmekle başlar.

Serinin sonraki yazısı: Composition vs Inheritance: Java’da Hangisini Seçmeli? Bu makalede kalıtım ile nesne bileşimini Java üzerinden karşılaştıracak, LSP ve OCP ile ilişkilerini gerçek örnekler üzerinde inceleyeceğiz.

Kaynaklar ve İleri Okuma

  1. Robert C. Martin — Agile Software Development, Principles, Patterns, and Practices, Prentice Hall, 2002.
  2. Robert C. Martin — Clean Architecture: A Craftsman’s Guide to Software Structure and Design, Prentice Hall, 2017.
  3. Barbara Liskov, Jeannette Wing — A Behavioral Notion of Subtyping, ACM Transactions on Programming Languages and Systems, 1994.
  4. Joshua Bloch — Effective Java, Third Edition, Addison-Wesley, 2018.
  5. Oracle — Java SE 21 Language Documentation: https://docs.oracle.com/en/java/javase/21/language/
  6. OpenJDK — JEP 395: Records: https://openjdk.org/jeps/395
  7. OpenJDK — JEP 409: Sealed Classes: https://openjdk.org/jeps/409
  8. OpenJDK — JEP 441: Pattern Matching for switch: https://openjdk.org/jeps/441
  9. Martin Fowler — Refactoring: https://refactoring.com/

Bir sonraki ne okunmalı?

Java Tasarım Desenleri (Design Patterns): Gerçek Hayat Örnekleri ve Modern Java

Bir Java uygulamasında yeni özellik geliştirmek genellikle kolaydır. Asıl zorluk, uygulama büyüdükçe mevcut kodu bozmadan yeni davranışlar ekleyebilmektir. Örneğin bir e-ticaret uygulaması düşünelim. İlk…

21 dk okuma süresi
Sonraki yazıyı oku

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir