PHP'de arayüz (interface) ve bağımlılık enjeksiyonu pratiği
PHP'de interface tanımlayarak ve bağımlılık enjeksiyonu uygulayarak sınıfları nasıl gevşek bağlı hale getirdiğimi örneklerle aktarıyorum.
E-posta göndermesi gereken bir sınıf yazıyorsunuz. İçine new SwiftMailer() yazarsanız ne olur? O sınıf artık SwiftMailer’a sıkı sıkıya bağlıdır. Gün gelir kütüphaneyi değiştirmeniz gerekir ya da test yazarken gerçek e-posta göndermek istemezsiniz — o zaman o sınıfa tekrar dokunmak zorunda kalırsınız.
Interface ve dependency injection, bu sorunu çözmek için birlikte kullanılan iki kavramdır. Birkaç aydır bunları kendi projelerimde daha bilinçli biçimde uygulamaya çalışıyorum; öğrendiklerimi bu yazıda derledim.
Interface nedir?
Interface, bir sınıfın ne yapabileceğini tanımlayan bir sözleşmedir; nasıl yapacağını değil. Interface’i uygulayan (implement eden) sınıf, interface’de tanımlanan tüm metodları gerçekleştirmek zorundadır.
<?php
interface MailerInterface
{
public function gonder(string $alici, string $konu, string $icerik): bool;
}
Bu interface’i uygulayan iki farklı sınıf yazalım:
<?php
class SmtpMailer implements MailerInterface
{
public function gonder(string $alici, string $konu, string $icerik): bool
{
// Gerçek SMTP bağlantısı kurarak e-posta gönder
return mail($alici, $konu, $icerik);
}
}
class LogMailer implements MailerInterface
{
public function gonder(string $alici, string $konu, string $icerik): bool
{
// Gerçek e-posta göndermek yerine log dosyasına yaz
file_put_contents('mail.log', "[{$alici}] {$konu}\n", FILE_APPEND);
return true;
}
}
Her iki sınıf da MailerInterface’i karşılıyor. Dışarıdan bakıldığında ikisi de aynı şeyi yapabiliyor: gonder() metodunu kabul ediyor.
Dependency injection nedir?
Dependency injection, bir sınıfın ihtiyaç duyduğu nesneleri kendi içinde oluşturmak yerine dışarıdan almasıdır. Genellikle constructor üzerinden yapılır.
<?php
class UserNotifier
{
private MailerInterface $mailer;
public function __construct(MailerInterface $mailer)
{
$this->mailer = $mailer;
}
public function kayitOlduBildir(string $email): bool
{
return $this->mailer->gonder(
$email,
'Kaydınız tamamlandı',
'Sisteme başarıyla kaydoldunuz. Hoşgeldiniz!'
);
}
}
UserNotifier artık SmtpMailer’ı bilmiyor. Yalnızca MailerInterface’i biliyor. Hangi gerçek uygulamanın kullanılacağı dışarıdan belirleniyor:
<?php
// Üretim ortamı: gerçek e-posta
$notifier = new UserNotifier(new SmtpMailer());
$notifier->kayitOlduBildir('kullanici@example.com');
// Geliştirme ortamı: yalnızca loglama
$notifier = new UserNotifier(new LogMailer());
$notifier->kayitOlduBildir('test@example.com');
UserNotifier değişmiyor. Davranışı, dışarıdan enjekte ettiğiniz nesneyle şekilleniyor.
Bu kalıp ilk bakışta fazla dolaylı görünüyor. Ama bir düşünün: SwiftMailer’dan başka bir kütüphaneye geçmeniz gerektiğinde UserNotifier’a hiç dokunmuyorsunuz. Yeni bir SmtpMailer sınıfı yazıyorsunuz, interface’i karşılıyor mu diye PHP kontrol ediyor, bitti. UserNotifier’ı test ederken gerçek e-posta sunucusuna bağlanmanız gerekmiyor. Bu ikisi çözdüğü sorun.
Test yazarken bu farkı açıkça görüyorsunuz
UserNotifier’ı test etmek istediğinizde gerçek e-posta göndermek istemezsiniz. Interface sayesinde test için özel bir uygulama yazabiliyorsunuz:
<?php
class FakeMailer implements MailerInterface
{
public array $gonderilen = [];
public function gonder(string $alici, string $konu, string $icerik): bool
{
$this->gonderilen[] = compact('alici', 'konu', 'icerik');
return true;
}
}
// Test kodu
$fakeMailer = new FakeMailer();
$notifier = new UserNotifier($fakeMailer);
$notifier->kayitOlduBildir('test@example.com');
assert(count($fakeMailer->gonderilen) === 1);
assert($fakeMailer->gonderilen[0]['alici'] === 'test@example.com');
Gerçek SMTP bağlantısı yok, test hızlı çalışıyor ve neyin gönderildiğini doğrulayabiliyorsunuz.
Arayüz olmadan bu testi yazmak için SmtpMailer sınıfını test sırasında değiştirmek ya da UserNotifier’ın içine koşullu mantık eklemek gerekir. İkisi de kötü seçenek.
Arayüzü her yerde kullanmak zorunda değilsiniz
Bir sınıfın yalnızca tek bir uygulaması olacaksa ve değişmesi planlanmıyorsa, arayüz eklemek gereksiz karmaşıklık yaratabilir. Arayüzün değerini hissettiren durumlar şunlar:
- Birden fazla uygulaması olacak şeyler (ödeme, e-posta, depolama gibi).
- Test sırasında gerçek uygulama yerine sahte (fake/mock) kullanmak istediğiniz bağımlılıklar.
- Dışarıdan konfigüre edilmesi beklenen davranışlar.
Her sınıfa arayüz eklemek zorunlu değil; doğru yerde kullanmak önemli.
Başlarken yaptığım hata her şeye arayüz eklemek oldu. UserRepository var, UserRepositoryInterface var. OrderService var, OrderServiceInterface var. Proje bir süre sonra gerçek sınıf sayısının iki katı dosyaya sahip oldu; arayüzlerin çoğu tek bir uygulamaya sahipti ve hiçbir zaman değişmedi. Bu gereksiz soyutlamadan vazgeçmek biraz zaman aldı.
Laravel container bunu kolaylaştırıyor
Laravel’de arayüzü somut bir sınıfa bağlamak için servis container’ı kullanıyorum:
// AppServiceProvider veya ayrı bir provider içinde
$this->app->bind(MailerInterface::class, SmtpMailer::class);
Artık UserNotifier container üzerinden oluşturulduğunda, Laravel MailerInterface bağımlılığını otomatik olarak SmtpMailer ile karşılıyor. Ortam değişkenine göre farklı bir uygulama bağlamak da mümkün:
if (app()->environment('production')) {
$this->app->bind(MailerInterface::class, SmtpMailer::class);
} else {
$this->app->bind(MailerInterface::class, LogMailer::class);
}
Bu kayıt bir kez yapılıyor; bundan sonra UserNotifier’ı nerede oluşturursanız oluşturun, doğru mailer otomatik enjekte ediliyor.
Bu iki kavramı bir arada kullanmak, başlangıçta biraz soyut hissettiriyor. Ama pratiğe döküldüğünde kod tabanının ne kadar esnekleştiğini görünce mantığı oturuyor.