Domain-Driven Design in Laravel
Understanding Domain-Driven Design (DDD)
Domain-Driven Design is a strategic approach to software development that focuses on modeling your application around the business domain rather than technical concerns. In Laravel, DDD helps organize complex applications by separating business logic from framework code.
Core DDD Concepts
DDD introduces several key concepts that help structure enterprise applications:
// Traditional Laravel Structure
app/
├── Models/
│ ├── User.php
│ ├── Order.php
│ └── Product.php
// DDD Structure with Bounded Contexts
app/
├── Domain/
│ ├── Sales/ // Bounded Context
│ │ ├── Models/
│ │ │ ├── Order.php
│ │ │ └── OrderItem.php
│ │ ├── ValueObjects/
│ │ │ ├── Money.php
│ │ │ └── OrderStatus.php
│ │ ├── Events/
│ │ │ └── OrderPlaced.php
│ │ └── Services/
│ │ └── OrderService.php
│ ├── Catalog/ // Another Bounded Context
│ │ ├── Models/
│ │ │ └── Product.php
│ │ └── ValueObjects/
│ │ └── Price.php
│ └── Identity/ // User Management Context
│ ├── Models/
│ │ └── User.php
│ └── ValueObjects/
│ └── Email.php
Value Objects
Value Objects are immutable objects that represent descriptive aspects of the domain with no conceptual identity. They are defined by their attributes rather than an ID.
<?php
namespace App\Domain\Sales\ValueObjects;
class Money
{
private float $amount;
private string $currency;
public function __construct(float $amount, string $currency = 'USD')
{
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
$this->amount = $amount;
$this->currency = strtoupper($currency);
}
public function amount(): float
{
return $this->amount;
}
public function currency(): string
{
return $this->currency;
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
return new self($this->amount + $other->amount, $this->currency);
}
public function multiply(float $multiplier): self
{
return new self($this->amount * $multiplier, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
public function format(): string
{
return $this->currency . ' ' . number_format($this->amount, 2);
}
}
Entities and Aggregates
Entities are objects with a distinct identity that runs through time. Aggregates are clusters of entities and value objects that are treated as a single unit for data changes.
<?php
namespace App\Domain\Sales\Models;
use App\Domain\Sales\ValueObjects\Money;
use App\Domain\Sales\Events\OrderPlaced;
use Illuminate\Database\Eloquent\Model;
class Order extends Model // Aggregate Root
{
protected $fillable = ['customer_id', 'status', 'total_amount', 'currency'];
protected $casts = [
'placed_at' => 'datetime',
];
// Domain Events
protected $dispatchesEvents = [
'created' => OrderPlaced::class,
];
// Relationships (Aggregate members)
public function items()
{
return $this->hasMany(OrderItem::class);
}
// Business Logic Methods
public function addItem(int $productId, int $quantity, Money $price): void
{
// Validate business rules
if ($quantity <= 0) {
throw new \InvalidArgumentException('Quantity must be positive');
}
if ($this->status !== 'draft') {
throw new \DomainException('Cannot add items to non-draft order');
}
$this->items()->create([
'product_id' => $productId,
'quantity' => $quantity,
'unit_price' => $price->amount(),
'currency' => $price->currency(),
]);
$this->recalculateTotal();
}
public function place(): void
{
if ($this->items->isEmpty()) {
throw new \DomainException('Cannot place order without items');
}
if ($this->status !== 'draft') {
throw new \DomainException('Order already placed');
}
$this->update([
'status' => 'placed',
'placed_at' => now(),
]);
}
private function recalculateTotal(): void
{
$total = $this->items->sum(function ($item) {
return $item->quantity * $item->unit_price;
});
$this->update(['total_amount' => $total]);
}
// Value Object Accessor
public function total(): Money
{
return new Money($this->total_amount, $this->currency);
}
}
Domain Events
Domain Events represent something significant that happened in the domain. They are used to communicate between bounded contexts and trigger side effects.
<?php
namespace App\Domain\Sales\Events;
use App\Domain\Sales\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPlaced
{
use Dispatchable, SerializesModels;
public Order $order;
public \DateTimeImmutable $occurredAt;
public function __construct(Order $order)
{
$this->order = $order;
$this->occurredAt = new \DateTimeImmutable();
}
public function orderId(): int
{
return $this->order->id;
}
public function customerId(): int
{
return $this->order->customer_id;
}
public function totalAmount(): float
{
return $this->order->total_amount;
}
}
// Event Listener
namespace App\Domain\Sales\Listeners;
use App\Domain\Sales\Events\OrderPlaced;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Notifications\Services\NotificationService;
class HandleOrderPlaced
{
private InventoryService $inventoryService;
private NotificationService $notificationService;
public function __construct(
InventoryService $inventoryService,
NotificationService $notificationService
) {
$this->inventoryService = $inventoryService;
$this->notificationService = $notificationService;
}
public function handle(OrderPlaced $event): void
{
// Reserve inventory
$this->inventoryService->reserveForOrder($event->order);
// Send notification
$this->notificationService->notifyOrderPlaced($event->order);
}
}
Domain Services
Domain Services encapsulate business logic that doesn't naturally fit within entities or value objects, especially operations that involve multiple aggregates.
<?php
namespace App\Domain\Sales\Services;
use App\Domain\Sales\Models\Order;
use App\Domain\Catalog\Repositories\ProductRepository;
use App\Domain\Inventory\Services\InventoryService;
class OrderService
{
private ProductRepository $productRepository;
private InventoryService $inventoryService;
public function __construct(
ProductRepository $productRepository,
InventoryService $inventoryService
) {
$this->productRepository = $productRepository;
$this->inventoryService = $inventoryService;
}
public function createOrderFromCart(int $customerId, array $cartItems): Order
{
// Validate inventory availability across multiple products
foreach ($cartItems as $item) {
if (!$this->inventoryService->isAvailable($item['product_id'], $item['quantity'])) {
throw new \DomainException(
"Product {$item['product_id']} not available in requested quantity"
);
}
}
// Create order (aggregate root)
$order = Order::create([
'customer_id' => $customerId,
'status' => 'draft',
'currency' => 'USD',
]);
// Add items to order
foreach ($cartItems as $item) {
$product = $this->productRepository->findById($item['product_id']);
$order->addItem(
$product->id,
$item['quantity'],
$product->price()
);
}
// Place the order
$order->place();
return $order;
}
public function calculateDiscount(Order $order): float
{
// Cross-aggregate business rule
$total = $order->total()->amount();
if ($total >= 1000) {
return 0.15; // 15% discount
} elseif ($total >= 500) {
return 0.10; // 10% discount
} elseif ($total >= 200) {
return 0.05; // 5% discount
}
return 0;
}
}
Ubiquitous Language
The ubiquitous language is a shared vocabulary used by both developers and domain experts. Code should reflect this language exactly.
placeOrder() or place() method, not createOrder() or submitOrder().
Product aggregate root with a Price value object. Include methods to:
- Apply percentage discount (returns new Price)
- Change price (with business rule validation)
- Check if price is within acceptable range
Customer bounded context with:
- Customer entity (aggregate root)
- Email and Address value objects
- CustomerRegistered domain event
- Business rule: Customer must have valid email and at least one address
PaymentService domain service that:
- Coordinates between Order and Payment aggregates
- Validates payment amount matches order total
- Dispatches OrderPaid event on success
- Handles payment failure scenarios