Laravel’de Domain Katmanı: Action + DTO ile Controller’ları İncelterek Sürdürülebilir Kod
Controller’ları incelten Action ve DTO yaklaşımıyla Laravel’de domain odaklı, okunabilir ve test edilebilir yapı kurun.
Laravel projeleri büyüdükçe controller’lar hızla “her şeyi yapan” dosyalara dönüşebilir. Bu yazıda, mevcut alışıldık konuların (API, queue, log, test vs.) dışına çıkıp domain katmanını netleştiren bir pratik ele alacağız: Action (Use Case) + DTO (Data Transfer Object) yaklaşımı.
Neden Action + DTO?
- Tek sorumluluk: Controller sadece HTTP ile ilgilenir.
- Yeniden kullanım: Aynı iş akışı (use case) CLI/Job/Controller’dan çağrılabilir.
- Daha okunabilir validasyon: “Request validated array” yerine anlamlı bir DTO.
- Test kolaylığı: HTTP’siz, saf PHP ile use case testi.
Örnek Senaryo: Ürün İndirimi Uygulama
Bir e-ticarette “ürüne yüzde indirim uygula” işini ele alalım.
1) DTO: İşi temsil eden veri
app/Domain/Pricing/ApplyDiscountData.php
<?php
namespace App\Domain\Pricing;
final class ApplyDiscountData
{
public function __construct(
public readonly int $productId,
public readonly int $percent,
public readonly int $actorUserId,
) {}
public static function from(array $validated, int $actorUserId): self
{
return new self(
productId: (int) $validated['product_id'],
percent: (int) $validated['percent'],
actorUserId: $actorUserId,
);
}
}
2) Action: Use case’i yapan sınıf
app/Domain/Pricing/ApplyDiscountAction.php
<?php
namespace App\Domain\Pricing;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use DomainException;
final class ApplyDiscountAction
{
public function execute(ApplyDiscountData $data): Product
{
if ($data->percent < 1 || $data->percent > 90) {
throw new DomainException('İndirim yüzde 1-90 arası olmalıdır.');
}
return DB::transaction(function () use ($data) {
$product = Product::lockForUpdate()->findOrFail($data->productId);
$newPrice = (int) round($product->price * (100 - $data->percent) / 100);
if ($newPrice >= $product->price) {
throw new DomainException('Yeni fiyat eski fiyattan düşük olmalı.');
}
$product->update([
'price' => $newPrice,
'discount_percent' => $data->percent,
'discounted_by' => $data->actorUserId,
]);
return $product->refresh();
});
}
}
3) Request: Validasyonu HTTP katmanında tut
app/Http/Requests/ApplyDiscountRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ApplyDiscountRequest extends FormRequest
{
public function rules(): array
{
return [
'product_id' => ['required', 'integer', 'exists:products,id'],
'percent' => ['required', 'integer', 'min:1', 'max:90'],
];
}
}
4) Controller: İnce ve net
app/Http/Controllers/ProductDiscountController.php
<?php
namespace App\Http\Controllers;
use App\Domain\Pricing\ApplyDiscountAction;
use App\Domain\Pricing\ApplyDiscountData;
use App\Http\Requests\ApplyDiscountRequest;
use DomainException;
class ProductDiscountController
{
public function __invoke(ApplyDiscountRequest $request, ApplyDiscountAction $action)
{
try {
$dto = ApplyDiscountData::from($request->validated(), $request->user()->id);
$product = $action->execute($dto);
return response()->json([
'ok' => true,
'product' => $product,
]);
} catch (DomainException $e) {
return response()->json([
'ok' => false,
'message' => $e->getMessage(),
], 422);
}
}
}
Pratik İpuçları
- Klasörleme:
app/Domain/Pricinggibi domain bazlı bir yapı, “Services” klasöründen daha anlamlıdır. - İsimlendirme:
ActionyerineUseCaseveyaServicede kullanılabilir; önemli olan niyetin açık olması. - Domain hataları:
DomainException(veya özel exception sınıfları) ile “iş kuralı” ihlallerini ayırın. - Yeniden kullanım: Aynı
ApplyDiscountActionbir artisan command veya admin panelinden de çağrılabilir.
Sonuç
Action + DTO yaklaşımı, Laravel’in esnek yapısını bozmadan domain odaklı, okunabilir ve bakımı kolay bir mimari kurmanıza yardım eder. Controller’lar incelir, iş kuralları tek yerde toplanır ve proje büyüdükçe “spagetti”leşme riski azalır.