Architecture en couches
Le DDD s'organise en couches, du plus abstrait au plus concret.
Domain
Le cœur métier : Entities, Value Objects, Aggregates, Domain Events, Interfaces de Repositories.
Application
Orchestration : Use Cases, Command/Query Handlers, DTOs. Aucune logique métier ici.
Infrastructure
Implémentations techniques : Doctrine, API externes, système de fichiers, mail.
Interface (UI / API)
Contrôleurs, CLI, serialization. Point d'entrée vers l'application.
💬 Ubiquitous Language
Tout le code doit utiliser le même vocabulaire que les experts métier.
Pas de traduction entre le code et le business — Order, Invoice,
Shipment dans le code comme dans les réunions.
Entities
Une Entity possède une identité unique qui persiste dans le temps. Deux entités avec les mêmes attributs mais des IDs différents sont deux objets distincts. L'entité encapsule sa logique métier et protège ses invariants.
// “Modèle anémique” : un simple sac de données
// Aucune logique, aucun invariant
class User
{
public int $id;
public string $email;
public string $status;
public string $name;
}
// La logique est dispersée dans des services...
$user->status = 'banned'; // Aucune vérification!
$user->email = 'nope'; // Email invalide? OK...
final class User
{
private function __construct(
private readonly UserId $id,
private Email $email,
private UserName $name,
private UserStatus $status,
) {}
public static function register(
Email $email, UserName $name
): self {
return new self(
UserId::generate(),
$email, $name,
UserStatus::Active,
);
}
public function ban(): void
{
if ($this->status === UserStatus::Banned) {
throw new AlreadyBanned($this->id);
}
$this->status = UserStatus::Banned;
}
}
Règle d'or : Une entité ne doit jamais être dans un état invalide. Le constructeur privé + factory method garantissent que l'objet est toujours cohérent dès sa création.
Value Objects
Un Value Object est immutable et comparé par valeur.
Il encapsule une règle métier et remplace les types primitifs
(string, int) par des concepts métier explicites.
final readonly class Email
{
private function __construct(
public string $value,
) {}
public static function from(string $email): self
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmail($email);
}
return new self(mb_strtolower($email));
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
final readonly class Money
{
public function __construct(
public int $cents,
public Currency $currency,
) {
if ($cents < 0) {
throw new NegativeAmount();
}
}
// Immutable : retourne une NOUVELLE instance
public function add(self $other): self
{
if (!$this->currency->equals($other->currency)) {
throw new CurrencyMismatch();
}
return new self(
$this->cents + $other->cents,
$this->currency,
);
}
public function equals(self $other): bool
{
return $this->cents === $other->cents
&& $this->currency->equals($other->currency);
}
}
Primitives Obsession : Remplacez string $email par Email $email,
float $price par Money $price. Vos types deviennent votre documentation
et la validation est garantie partout.
Aggregates
Un Aggregate est un cluster d'entités et de value objects traité comme une unité. Toute modification passe par la racine (Aggregate Root), qui protège les invariants métier du groupe entier.
final class Order // ← Aggregate Root
{
/** @var OrderLine[] */
private array $lines = [];
private function __construct(
private readonly OrderId $id,
private readonly CustomerId $customerId,
private OrderStatus $status,
) {}
public static function place(CustomerId $customerId): self
{
return new self(OrderId::generate(), $customerId, OrderStatus::Draft);
}
// Toute modification passe par l'Aggregate Root
public function addLine(ProductId $productId, Money $price, int $qty): void
{
if ($this->status !== OrderStatus::Draft) {
throw new CannotModifyConfirmedOrder();
}
if (count($this->lines) >= 20) {
throw new OrderLineLimitReached(); // Invariant métier
}
$this->lines[] = new OrderLine($productId, $price, $qty);
}
public function confirm(): void
{
if (empty($this->lines)) {
throw new EmptyOrderCannotBeConfirmed();
}
$this->status = OrderStatus::Confirmed;
}
public function total(): Money
{
return array_reduce(
$this->lines,
fn(Money $sum, OrderLine $l) => $sum->add($l->subtotal()),
new Money(0, Currency::EUR),
);
}
}
Règle : On ne référence un Aggregate que par l'ID de sa racine.
Order contient un CustomerId, pas un objet Customer.
Cela garantit des frontières transactionnelles claires.
Repositories
Le Repository offre une illusion de collection en mémoire pour les Aggregates. L'interface vit dans le domaine, l'implémentation dans l'infrastructure. Le domaine ne connaît ni SQL, ni Doctrine, ni Redis.
// Interface dans la couche Domain
// Aucune dépendance technique
interface OrderRepository
{
/**
* @throws OrderNotFound
*/
public function get(OrderId $id): Order;
public function save(Order $order): void;
/**
* @return Order[]
*/
public function findByCustomer(
CustomerId $customerId,
): array;
}
// Note : pas de delete() si le métier
// ne supprime jamais de commandes !
// Implémentation technique dans Infrastructure
final readonly class DoctrineOrderRepository
implements OrderRepository
{
public function __construct(
private EntityManagerInterface $em,
) {}
public function get(OrderId $id): Order
{
return $this->em->find(Order::class, $id)
?? throw new OrderNotFound($id);
}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
public function findByCustomer(
CustomerId $customerId,
): array {
return $this->em
->getRepository(Order::class)
->findBy(['customerId' => $customerId]);
}
}
Port / Adapter : L'interface est un Port (domaine),
l'implémentation Doctrine est un Adapter (infra).
Pour les tests, créez un InMemoryOrderRepository — zéro base de données, tests ultra-rapides.
Domain Events
Un Domain Event représente un fait passé dans le domaine : « une commande a été confirmée ». Il permet de découpler les effets de bord (email, stats, sync) de la logique métier principale.
// Un fait immutable au passé
final readonly class OrderConfirmed
{
public function __construct(
public OrderId $orderId,
public CustomerId $customerId,
public Money $total,
public \DateTimeImmutable $occurredAt,
) {}
}
// Dans l'Aggregate Root Order :
public function confirm(): void
{
// ... validations ...
$this->status = OrderStatus::Confirmed;
$this->recordEvent(new OrderConfirmed(
$this->id,
$this->customerId,
$this->total(),
new \DateTimeImmutable(),
));
}
// Un listener réagit à l'événement
// Découplé de la logique de commande
#[AsEventListener(OrderConfirmed::class)]
final readonly class SendOrderConfirmationEmail
{
public function __construct(
private Mailer $mailer,
private CustomerRepository $customers,
) {}
public function __invoke(
OrderConfirmed $event,
): void {
$customer = $this->customers
->get($event->customerId);
$this->mailer->send(
new ConfirmationEmail(
$customer->email(),
$event->orderId,
$event->total,
),
);
}
}
Avantage : L'Aggregate Order n'a aucune idée
qu'un email sera envoyé. Vous pouvez ajouter 10 listeners (stats, logs, webhooks)
sans toucher au domaine — Open/Closed Principle appliqué naturellement.
Bounded Contexts
Un même mot peut avoir des significations différentes selon le contexte. Un Bounded Context définit les frontières où un modèle est valide. Chaque contexte a son propre Ubiquitous Language et ses propres modèles.
// Dans le contexte « Catalogue »
// Product = fiche produit affichée
namespace App\Catalog\Domain;
final class Product
{
public function __construct(
private readonly ProductId $id,
private string $name,
private string $description,
private Money $price,
private Category $category,
private array $images,
) {}
public function rename(string $name): void
{ /* ... */ }
public function reprice(Money $price): void
{ /* ... */ }
}
// Dans le contexte « Entrepôt »
// Product = stock physique à gérer
namespace App\Warehouse\Domain;
final class Product
{
public function __construct(
private readonly ProductId $id,
private string $sku,
private int $quantityOnHand,
private Location $location,
) {}
public function receive(int $qty): void
{
$this->quantityOnHand += $qty;
}
public function pick(int $qty): void
{
if ($qty > $this->quantityOnHand) {
throw new InsufficientStock();
}
$this->quantityOnHand -= $qty;
}
}
Structure de dossiers PHP : Chaque Bounded Context devient un module isolé :
src/Catalog/, src/Warehouse/, src/Billing/.
Ils communiquent entre eux via des Domain Events ou une couche anti-corruption,
jamais par accès direct aux classes internes.