DDD'nin PHP'deki Yeri
Domain Driven Design (DDD), karmaşık iş kurallarını yazılımın merkezine yerleştiren bir yaklaşımdır. PHP dünyasında DDD'yi uygulamak, çoğu projede alışılagelmiş "controller-service-repository" üçlüsünün ötesine geçmeyi gerektirir. Bu rehber, orta seviye bir PHP geliştiricisinin kendi projesinde DDD'nin temel yapı taşlarını nasıl kurabileceğini adım adım anlatır.
Önkoşullar: PHP 8.1 veya üzeri (enum, readonly property, never dönüş tipi gibi dil özellikleri için), Composer, temel nesne yönelimli programlama ve PSR-4 otomatik yükleme bilgisi. Framework zorunlu değildir; örnekler framework bağımsız tutulmuştur. Framework'e özgü entegrasyon (örn. Laravel service provider, Symfony bundle) ayrı bir konudur.
Katmanlar ve Bağımlılık Yönü
DDD'de katmanlar tipik olarak şöyle ayrılır:
- Domain: İş kuralları, entity'ler, value object'ler, domain servisleri, repository arayüzleri. Hiçbir dış katmana bağımlı olmaz.
- Application: Use case'ler. Domain'i orkestre eder, transaction sınırlarını yönetir.
- Infrastructure: Veritabanı, HTTP istemcileri, kuyruk, e-posta. Domain'de tanımlı arayüzleri uygular.
- Presentation: Controller, CLI komutu, GraphQL resolver. Girdi/çıktı dönüşümünü yapar.
Kritik kural: bağımlılıklar içe doğru akar. Domain, Infrastructure'ı tanımaz; Infrastructure, Domain'i tanır. Repository arayüzü Domain'de tanımlanır, implementasyonu Infrastructure'da yaşar.
Value Object ile Başlamak
Value object, kimliği olmayan, değeriyle tanımlanan ve değişmez (immutable) nesnedir. Para birimi, e-posta adresi, tarih aralığı tipik örneklerdir.
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
final class Email
{
private function __construct(private readonly string $value)
{
}
public static function fromString(string $value): self
{
$normalized = mb_strtolower(trim($value));
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Geçersiz e-posta adresi.');
}
return new self($normalized);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
Bu sınıfın beklenen davranışı: geçersiz girdide InvalidArgumentException fırlatır, geçerli girdide normalize edilmiş değeri tutar. final ve private kurucu sayesinde nesne yalnızca fromString üzerinden oluşturulur; bu, doğrulamanın atlanmasını engeller.
Entity ve Kimlik
Entity'nin value object'ten farkı, kimliğinin olmasıdır. İki farklı User nesnesi aynı e-posta ve isme sahip olsa bile farklı ID'ye sahipse farklı kullanıcılardır.
<?php
declare(strict_types=1);
namespace App\Domain\User;
use App\Domain\Shared\Email;
final class User
{
public function __construct(
private readonly UserId $id,
private Email $email,
private string $displayName,
) {
}
public function id(): UserId
{
return $this->id;
}
public function email(): Email
{
return $this->email;
}
public function changeEmail(Email $email): void
{
$this->email = $email;
}
public function displayName(): string
{
return $this->displayName;
}
}
Dikkat edilecek nokta: entity'nin durumunu değiştiren metotlar (changeEmail) iş kuralını içeride tutar. Örneğin "e-posta değişikliği yalnızca hesap doğrulanmışsa yapılabilir" gibi bir kural buraya yazılır, dışarıya değil.
Aggregate ve Sınırlar
Aggregate, tutarlılık sınırı olan bir entity kümesidir. Bir aggregate'in kökü (aggregate root) vardır ve dışarıdan yalnızca köke erişilir. Örneğin bir Order, OrderLine nesnelerini içerir; ancak OrderLine'lar doğrudan repository'den çekilmez, Order üzerinden erişilir.
Pratik kural: bir transaction içinde yalnızca bir aggregate değiştirilir. İki aggregate'i aynı transaction'da değiştirmek gerekiyorsa, bu genellikle sınırların yanlış çizildiğinin işaretidir veya eventual consistency (domain event ile) düşünülmelidir.
Repository Arayüzü Domain'de
Repository, koleksiyon benzeri bir soyutlamadır. Arayüz Domain'de, implementasyon Infrastructure'da olur.
<?php
declare(strict_types=1);
namespace App\Domain\User;
interface UserRepository
{
public function nextIdentity(): UserId;
public function findById(UserId $id): ?User;
public function save(User $user): void;
}
nextIdentity metodu, kimlik üretimini de soyutlar. Böylece Domain katmanı UUID, auto-increment veya başka bir stratejiyi bilmez.
Application Katmanı: Use Case
Use case, tek bir iş senaryosunu temsil eder. Controller'ın içine iş kuralı yazmak yerine buraya taşınır.
<?php
declare(strict_types=1);
namespace App\Application\User;
use App\Domain\Shared\Email;
use App\Domain\User\UserRepository;
final class ChangeUserEmail
{
public function __construct(private readonly UserRepository $users)
{
}
public function __invoke(string $userId, string $newEmail): void
{
$user = $this->users->findById(
$this->users->nextIdentity()->fromString($userId)
);
if ($user === null) {
throw new \RuntimeException('Kullanıcı bulunamadı.');
}
$user->changeEmail(Email::fromString($newEmail));
$this->users->save($user);
}
}
Burada dikkat: UserId'nin fromString gibi bir fabrika metodu olduğu varsayılmıştır. nextIdentity yalnızca yeni kimlik üretmek için kullanılır; mevcut bir kimliği string'den oluşturmak için ayrı bir metot gerekir. Bu örnekte nextIdentity()->fromString(...) çağrısı yanıltıcıdır; doğrusu UserId::fromString($userId) şeklinde statik bir fabrika olur. Arayüzü buna göre düzenlemek gerekir:
public static function fromString(string $value): self;
Bu tür küçük tutarsızlıklar DDD'de sık görülür; arayüzü ve implementasyonu birlikte tasarlarken kimlik üretimi ile kimlik okuma sorumluluklarını ayrı tutmak faydalıdır.
Domain Event ile Yan Etkileri Ayırmak
Bir kullanıcının e-postası değiştiğinde hoş geldin e-postası göndermek istiyorsanız, bunu User::changeEmail içine yazmayın. Domain event yayınlayın, dinleyici Infrastructure'da e-posta göndersin.
final class UserEmailChanged
{
public function __construct(
public readonly string $userId,
public readonly string $newEmail,
) {
}
}
Domain event'lerin toplanması ve dağıtılması (event dispatcher) genellikle Application veya Infrastructure katmanında yapılır. Domain, olayı yalnızca kaydeder; kimin dinlediğini bilmez.
Yaygın Hatalar
- Anemik domain modeli: Entity'ler yalnızca getter/setter içeriyorsa, iş kuralları servislere dağılmış demektir. DDD'nin amacı bu dağılmayı önlemektir.
- Domain'de framework bağımlılığı:
Illuminate\Database\Eloquent\ModelveyaDoctrine\ORM\Mappinggibi sınıflar Domain'de görünüyorsa sınır ihlali vardır. - Repository'nin sorgu koleksiyonuna dönüşmesi:
findByX,findByY,findByZmetodları çoğalıyorsa, muhtemelen use case'e özgü bir sorgu (query object) veya CQRS'in okuma tarafı gerekir. - Aşırı mühendislik: Basit CRUD uygulamalarında DDD maliyeti faydayı aşar. Karmaşık iş kuralları yoksa basit katmanlı mimari yeterlidir.
Güvenlik Notları
- Value object doğrulaması, girdi doğrulamasının yerini almaz. HTTP katmanında da tip ve uzunluk kontrolü yapılmalıdır; value object son savunma hattıdır.
- Repository implementasyonunda parametreli sorgular kullanın. Domain'de string birleştirmeyle SQL kurmayın.
- Domain event dinleyicileri e-posta veya webhook gibi dış çağrılar yapıyorsa, bu çağrıları transaction dışına taşıyın; aksi halde rollback durumunda tutarsız yan etkiler oluşur.
- Kimlik üretiminde tahmin edilebilir sıralı ID'ler yerine UUID gibi değerler tercih edin; ID'ler dışarıya sızıyorsa bu bir bilgi ifşası riskidir.
Test Yaklaşımı
Domain katmanı saf PHP olduğu için hızlı birim testleriyle doğrulanabilir. Infrastructure (veritabanı, HTTP) için entegrasyon testleri ayrı tutulur. Application use case'leri, repository arayüzünün in-memory implementasyonuyla test edilebilir; bu, gerçek veritabanına ihtiyaç duymadan iş akışını doğrulamayı sağlar.
Nereden Devam Etmeli
DDD'yi bir projeye tamamen uygulamak yerine, önce bir bounded context seçip yalnızca o modülde value object, entity ve repository ayrımını deneyin. İş kuralları netleştikçe aggregate sınırları ve domain event'ler kendiliğinden ortaya çıkar. Framework entegrasyonu, event dispatcher ve CQRS gibi konular bu temelin üzerine inşa edilir; acele etmeyin.