DDD Best Practices & Pitfalls

+15 Mana ✨

Introduction

After learning the patterns, let's discuss practical wisdom for successful DDD adoption.

Key Concepts

  • Anemic Domain Model - an anti-pattern where entities lack behavior
  • Bounded Context - an explicit boundary within which a domain model applies
  • Ubiquitous Language - the shared vocabulary between developers and domain experts
  • Aggregate Size - keeping aggregates small to minimize transaction conflicts

Real World Context

In production PHP applications, ddd best practices & pitfalls helps teams build maintainable software by providing clear patterns for organizing complex business logic.

Deep Dive

Best Practices

1. Start with the Domain, Not the Database

php
<?php
// DON'T: Design database first, then model
CREATE TABLE orders (...);  // Database first
class Order extends Model {}  // ORM entity second

// DO: Design domain model first
final class Order {  // Rich domain model
    public function submit(): void { ... }
}
// Then figure out persistence

2. Make Implicit Concepts Explicit

php
<?php
// IMPLICIT: Hidden in primitive types
class Order {
    private string $status;  // What values are valid?
    private float $discount;  // Percentage? Amount? Currency?
}

// EXPLICIT: Concepts named and enforced
class Order {
    private OrderStatus $status;  // Enum with valid states
    private DiscountPolicy $discount;  // Typed, with rules
}

enum OrderStatus: string {
    case Draft = 'draft';
    case Submitted = 'submitted';
    case Confirmed = 'confirmed';
    
    public function canBeCancelled(): bool {
        return $this !== self::Confirmed;
    }
}

3. Keep Aggregates Small

php
<?php
// TOO BIG: Everything in one aggregate
class Order {
    private Customer $customer;      // Should be ID only
    private array $payments;         // Separate aggregate
    private array $shipments;        // Separate aggregate
    private array $returns;          // Separate aggregate
    private array $reviews;          // Different context
}

// RIGHT SIZE: Focused on core invariants
class Order {
    private CustomerId $customerId;  // Reference only
    private array $lines;            // Part of order invariants
    private Money $total;            // Maintained by aggregate
}

4. Use Domain Events for Cross-Aggregate Communication

php
<?php
// BAD: Direct coupling between aggregates
class OrderService {
    public function submit(Order $order): void {
        $order->submit();
        
        // Direct manipulation of other aggregates
        $this->inventory->reduce(...);
        $this->customer->addPoints(...);
        $this->analytics->track(...);
    }
}

// GOOD: Events decouple aggregates
class OrderService {
    public function submit(Order $order): void {
        $order->submit();
        $this->orders->save($order);
        
        // Events handled by separate listeners
        $this->events->dispatch($order->pullDomainEvents());
    }
}

// Listeners handle side effects
class ReduceInventoryOnOrderSubmitted {
    public function __invoke(OrderSubmitted $event): void {
        // Handle in separate transaction
    }
}

5. Protect Invariants in Aggregates

php
<?php
class Order {
    // Invariant: Total must always be correct
    // Invariant: Cannot modify after submission
    
    public function addLine(OrderLine $line): void {
        $this->assertModifiable();  // Check invariant
        $this->lines[] = $line;
        $this->recalculateTotal();  // Maintain invariant
    }
    
    private function recalculateTotal(): void {
        // Always kept in sync
        $this->total = array_reduce(
            $this->lines,
            fn($sum, $line) => $sum->add($line->subtotal()),
            Money::zero()
        );
    }
}

Common Pitfalls

1. Anemic Domain Model

php
<?php
// ANEMIC: No behavior, just data
class Order {
    public string $status;
    public array $lines;
    public float $total;
}

class OrderService {
    public function submit(Order $order): void {
        if (empty($order->lines)) {
            throw new Exception('...');
        }
        $order->status = 'submitted';  // Logic outside entity
    }
}

// RICH: Behavior encapsulated
class Order {
    public function submit(): void {
        $this->assertHasLines();
        $this->status = OrderStatus::Submitted;
    }
}

2. Over-Engineering

php
<?php
// OVER-ENGINEERED: DDD for simple CRUD
class UserSettings {
    // Does this really need DDD?
    // Sometimes a simple model is enough
}

// Apply DDD where complexity exists
// Use simpler patterns for simple features

3. Ignoring Bounded Contexts

php
<?php
// WRONG: One model for everything
class Product {
    // Catalog concerns
    private string $description;
    private array $images;
    
    // Inventory concerns
    private int $stockLevel;
    private string $warehouseLocation;
    
    // Pricing concerns
    private Money $basePrice;
    private array $discountRules;
}

// RIGHT: Different models per context
namespace Catalog { class Product { /* display info */ } }
namespace Inventory { class StockItem { /* stock info */ } }
namespace Pricing { class PricedProduct { /* pricing */ } }

When NOT to Use DDD

  • Simple CRUD applications
  • Prototypes and MVPs
  • Well-understood, stable domains
  • Small teams with simple requirements
  • Short-lived projects

Summary Checklist

✅ Domain experts involved in modeling ✅ Ubiquitous language documented and enforced ✅ Bounded contexts identified ✅ Aggregates keep invariants consistent ✅ Repositories abstract persistence ✅ Domain events for cross-aggregate communication ✅ Application services orchestrate use cases ✅ Infrastructure depends on domain, not vice versa

✓ Completed