Advanced Laravel

Domain-Driven Design in Laravel

20 min Lesson 21 of 40

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:

Bounded Context: A logical boundary within which a particular domain model is defined and applicable. Each bounded context has its own ubiquitous language and models.
// 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.

Aggregate Root: The main entity through which all operations on the aggregate must pass. It enforces invariants and business rules across the entire aggregate.
<?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.

Don't Overuse Domain Services: Not all business logic belongs in services. First try to place logic in entities or value objects. Use services only for operations that involve coordination across 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.

Example: If business experts say "place an order", your code should have a placeOrder() or place() method, not createOrder() or submitOrder().
Exercise 1: Create a 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
Exercise 2: Design a 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
Exercise 3: Implement a 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
DDD Benefits: Better code organization, clearer business logic, improved testability, easier collaboration with domain experts, and more maintainable code as complexity grows.