ahmed-bhs/hexagonal-maker-bundle
CQRS = Command Query Responsibility Segregation (Ségrégation des Responsabilités entre Commandes et Requêtes)
Séparer le modèle qui écrit les données (Commands) du modèle qui lit les données (Queries).
// Un repository gère lectures et écritures
interface UserRepositoryInterface
{
// Écritures
public function save(User $user): void;
public function delete(User $user): void;
// Lectures
public function findById(UserId $id): ?User;
public function findAll(): array;
public function findActiveUsers(): array;
}
// Modèle écriture (Commands)
interface UserRepositoryInterface
{
public function save(User $user): void;
public function delete(User $user): void;
}
// Modèle lecture (Queries)
interface UserQueryInterface
{
public function findById(UserId $id): ?UserDTO;
public function findAll(): array; // array<UserDTO>
public function findActiveUsers(): array;
}
| Préoccupation | Écritures (Commands) | Lectures (Queries) |
|---|---|---|
| Focus | Règles métier, cohérence | Vitesse, dénormalisation |
| Modèle | Entités riches | DTOs simples |
| Validation | Logique métier complexe | Aucune (déjà validé) |
| Performance | Peut être lent (transactionnel) | Doit être rapide (caché) |
| Complexité | Graphes d'objets complexes | Projections plates |
Exemple : Commande E-Commerce
// ÉCRITURE : Entité complexe avec logique métier
class Order
{
private OrderId $id;
private CustomerId $customerId;
private array $items; // OrderItem[]
private Money $totalAmount;
private OrderStatus $status;
public function addItem(Product $product, int $quantity): void
{
// Règles métier complexes
if ($this->status !== OrderStatus::DRAFT) {
throw new CannotModifyConfirmedOrderException();
}
if ($quantity <= 0) {
throw new InvalidQuantityException();
}
$this->items[] = new OrderItem($product, $quantity);
$this->recalculateTotal();
}
}
// LECTURE : DTO simple pour affichage
final readonly class OrderListDTO
{
public function __construct(
public string $orderId,
public string $customerName,
public int $totalAmountCents,
public string $status,
public string $createdAt,
) {}
}
Pourquoi la séparation aide :
Requêtes écriture nécessitent :
Requêtes lecture nécessitent :
// ❌ Tenter de servir les deux besoins avec un modèle mène au compromis
// Écriture : nécessite entité complète
$order = $this->orders->findById($orderId); // Charge Order avec toutes relations
$order->addItem($product, 2);
$this->orders->save($order);
// Lecture : nécessite données plates pour affichage
$orders = $this->orders->findAllOrders(); // Même repository!
// Mais on n'a pas besoin d'entités complètes avec logique métier pour affichage...
// On charge trop de données, gaspille mémoire, et c'est lent
Solution CQRS : Modèles différents pour besoins différents.
Côté écriture :
// Entité complexe avec logique métier
$order->confirm(); // Logique domaine riche
$this->orders->save($order);
Côté lecture :
// Requête SQL optimisée, retourne DTO plat
$orders = $this->queryBus->dispatch(new FindOrdersQuery());
// SELECT o.id, o.status, c.name AS customer_name, ...
// FROM orders o JOIN customers c ON ...
// Résultat plat, pas de surcharge d'hydratation
Bénéfice : Les lectures peuvent être optimisées agressivement (dénormalisation, cache) sans affecter modèle écriture.
// ❌ Sans CQRS : lecture utilise entité complexe
$user = $this->users->findById($userId); // Retourne entité User complète
return new UserResponse(
id: $user->getId()->toString(),
email: $user->getEmail()->value,
name: $user->getName(),
// Extraire données de l'entité complexe
);
// ✅ Avec CQRS : lecture retourne DTO directement
$userDTO = $this->queries->findById($userId); // Retourne UserDTO
return $userDTO; // Déjà dans le bon format
┌─────────────┐ ┌─────────────┐
│ BD Écriture │ │ BD Lecture │
│ (Master) │────────>│ (Replicas) │
│ │ Sync │ │
│ 1 instance │ │ 10 replicas │
└─────────────┘ └─────────────┘
↑ ↑
10% trafic 90% trafic
Bénéfice : Mettre à l'échelle côtés lecture et écriture indépendamment selon charge.
// Commande : Écriture immédiate
$this->commandBus->dispatch(new CreateOrderCommand(...));
// Commande sauvegardée en BD écriture
// Requête : Lecture depuis replica (peut être légèrement retardée)
$orders = $this->queryBus->dispatch(new FindOrdersQuery());
// Lit depuis replica lecture (cohérence éventuelle)
Bénéfice : Accepter léger délai en lecture pour meilleur débit écriture.
// Modèle lecture : exposer seulement données sûres
interface PublicUserQueryInterface
{
public function findByUsername(string $username): ?PublicUserDTO;
// Retourne : username, bio, avatar (champs sûrs)
}
// Modèle écriture : contient données sensibles
interface UserRepositoryInterface
{
public function save(User $user): void;
// Entité User contient hash password, email (sensible)
}
Bénéfice : Modèles lecture peuvent exposer différentes projections pour différents utilisateurs (public vs admin).
Sans CQRS (simple) :
interface UserRepositoryInterface
{
public function save(User $user): void;
public function findById(UserId $id): ?User;
}
// 1 interface, 1 implémentation, 1 modèle
Avec CQRS (complexe) :
interface UserRepositoryInterface { /* méthodes écriture */ }
interface UserQueryInterface { /* méthodes lecture */ }
class DoctrineUserRepository implements UserRepositoryInterface { /* ... */ }
class DoctrineUserQuery implements UserQueryInterface { /* ... */ }
// 2 interfaces, 2 implémentations, 2 modèles (entité + DTO)
Coût : Double de code, double de maintenance.
Si bases de données écriture et lecture sont séparées :
// Écrire en BD écriture
$this->commandBus->dispatch(new CreateUserCommand(...));
// Doit synchroniser vers BD lecture
$this->eventBus->dispatch(new UserCreatedEvent(...));
// Gestionnaire événement met à jour BD lecture
class UserCreatedEventHandler
{
public function __invoke(UserCreatedEvent $event): void
{
$this->readDatabase->insertUser(...); // Sync!
}
}
Coût : Infrastructure supplémentaire (files messages, gestionnaires événements, logique sync).
// Utilisateur crée compte
$this->commandBus->dispatch(new RegisterUserCommand(...));
// Essaie immédiatement de se connecter
$user = $this->queries->findByEmail($email);
// ❌ Peut retourner null si BD lecture pas encore synchronisée!
Coût : Doit gérer problème "lire ses propres écritures", ajoutant complexité.
| Tâche | Sans CQRS | Avec CQRS |
|---|---|---|
| Ajouter nouvelle entité | 1 repository | 1 repository + 1 interface query + sync |
| Ajouter opération lecture | Ajouter méthode au repository | Ajouter méthode à interface query |
| Ajouter opération écriture | Ajouter méthode au repository | Ajouter méthode + événement + gestionnaire sync |
| Tests | Tester repository | Tester repository + query + sync + cohérence éventuelle |
Coût : 30-50% plus de temps développement pour opérations CRUD.
Coût : Temps formation, erreurs pendant phase apprentissage.
Exemple : Tableau de Bord Analytics
// Écritures : rares (une fois par heure, tâche fond)
$this->commandBus->dispatch(new GenerateReportCommand(...));
// Lectures : fréquentes (milliers par seconde)
$report = $this->queries->getReport($reportId);
Pourquoi CQRS aide : Optimiser côté lecture agressivement (cache, dénormalisation) sans impacter écritures rares.
Exemple : Tableau de Bord Admin E-Commerce
// Modèle écriture : entités normalisées
Order -> OrderItem -> Product
Customer -> Address
// Modèle lecture : vue dénormalisée
interface AdminDashboardQueryInterface
{
public function getOrderSummary(): OrderSummaryDTO;
// Retourne : total commandes, revenu, valeur commande moyenne, top produits
// Tout dénormalisé en une seule requête optimisée
}
Pourquoi CQRS aide : Modèle lecture peut être dénormalisé pour reporting rapide sans polluer modèle écriture.
Exemple : Catalogue Produits
// Modèle écriture : entité Product unique
class Product { /* logique métier */ }
// Modèles lecture : multiples projections
interface ProductListQueryInterface
{
public function findAll(): array; // Liste simple
}
interface ProductDetailQueryInterface
{
public function findById(ProductId $id): ProductDetailDTO; // Détails complets
}
interface ProductSearchQueryInterface
{
public function search(string $query): array; // Elasticsearch
}
Pourquoi CQRS aide : Différents modèles lecture pour différents cas d'usage sans couplage.
// Écriture : doit être fortement cohérent
$this->orderRepository->save($order); // Cohérence immédiate
// Lecture : peut être éventuellement cohérent
$orders = $this->orderQuery->findRecent(); // Léger délai OK
Pourquoi CQRS aide : Accepter cohérence éventuelle en lecture pour améliorer débit écriture.
// Écriture : événements stockés
$this->commandBus->dispatch(new UpdatePriceCommand(...));
// Produit : PriceUpdatedEvent stocké dans event store
// Lecture : vue matérialisée depuis événements
$product = $this->queries->findById($productId);
// Reconstruit depuis événements ou projection cachée
Pourquoi CQRS aide : S'adapte naturellement avec event sourcing (événements = modèle écriture, projections = modèle lecture).
// Juste créer, lire, mettre à jour, supprimer utilisateurs
// ❌ CQRS est excessif ici
interface UserRepositoryInterface
{
public function save(User $user): void;
public function findById(UserId $id): ?User;
public function findAll(): array;
public function delete(User $user): void;
}
// ✅ Repository unique suffit
Pourquoi éviter CQRS : Pas de goulot performance, pas de requêtes complexes, complexité inutile.
// Banque : utilisateur vérifie solde, puis retire
$balance = $this->accountQuery->getBalance($accountId);
// ❌ Si BD lecture désynchronisée, affiche mauvais solde!
$this->commandBus->dispatch(new WithdrawCommand($accountId, $amount));
// ❌ Peut permettre découvert à cause lecture obsolète
Pourquoi éviter CQRS : Cohérence éventuelle peut causer bugs dans scénarios nécessitant cohérence forte.
Pourquoi éviter CQRS : Surcharge pas valable, ralentira livraison.
quêtes par minute
Pourquoi éviter CQRS : Pas de problème performance à résoudre, optimisation prématurée.
Pourquoi éviter CQRS : CQRS optimise lectures, mais ce système est intensif écriture.
interface UserRepositoryInterface
{
public function save(User $user): void;
public function findById(UserId $id): ?User;
}
Complexité : Faible Quand utiliser : Petites apps, CRUD simple
// Interface écriture
interface UserRepositoryInterface
{
public function save(User $user): void;
}
// Interface lecture
interface UserQueryInterface
{
public function findById(UserId $id): ?UserDTO;
}
// Les deux utilisent même BD, interfaces différentes
Complexité : Moyenne Quand utiliser : Séparation logique, même BD
// Écriture : utilise entités
class DoctrineUserRepository implements UserRepositoryInterface
{
public function save(User $user): void { /* ORM */ }
}
// Lecture : utilise SQL brut
class SqlUserQuery implements UserQueryInterface
{
public function findById(UserId $id): ?UserDTO
{
// SQL brut optimisé pour lectures
$stmt = $this->connection->executeQuery('SELECT ...');
return $this->hydrateDTO($stmt->fetchAssociative());
}
}
Complexité : Moyenne-Élevée Quand utiliser : Optimiser lectures, toujours BD unique
// Écriture : BD Master
class DoctrineUserRepository implements UserRepositoryInterface
{
public function save(User $user): void
{
$this->entityManager->persist($user); // BD écriture
$this->eventBus->dispatch(new UserSavedEvent($user)); // Déclencher sync
}
}
// Lecture : BD Replica
class ReplicaUserQuery implements UserQueryInterface
{
public function findById(UserId $id): ?UserDTO
{
return $this->replicaConnection->fetchOne(...); // BD lecture
}
}
// Gestionnaire événement synchronise écriture → lecture
class UserSavedEventHandler
{
public function __invoke(UserSavedEvent $event): void
{
$this->readDatabase->upsertUser(...); // Sync
}
}
Complexité : Élevée Quand utiliser : Grande échelle, mise à l'échelle indépendante nécessaire
Scénario :
Lectures : Voir articles (99% du trafic) Écritures : Publier articles (1% du trafic)
Décision : ✅ Utiliser CQRS Niveau 1
// Écriture : entité avec logique métier
interface ArticleRepositoryInterface
{
public function save(Article $article): void;
}
// Lecture : DTOs optimisés
interface ArticleQueryInterface
{
public function findPublished(int $limit, int $offset): array;
public function findBySlug(string $slug): ?ArticleDetailDTO;
}
Raison : Ratio lecture/écriture élevé, même BD convient, séparation logique aide.
Scénario :
Décision : ❌ Ne pas utiliser CQRS
// Repository unique suffit
interface TaskRepositoryInterface
{
public function save(Task $task): void;
public function findById(TaskId $id): ?Task;
public function findByUser(UserId $userId): array;
public function delete(Task $task): void;
}
Raison : Pas de problème performance, pas de requêtes complexes, CQRS ajoute complexité inutile.
Scénario :
Décision : ✅ Utiliser CQRS Niveau 2-3
// Écriture : entités normalisées
interface OrderRepositoryInterface
{
public function save(Order $order): void;
}
// Lecture : projections dénormalisées
interface AdminDashboardQueryInterface
{
public function getSalesReport(): SalesReportDTO;
public function getInventoryStatus(): InventoryDTO;
}
interface OrderQueryInterface
{
public function findRecent(int $limit): array;
}
Raison : Reporting complexe, volume lecture élevé, cohérence éventuelle acceptable pour tableaux de bord.
Scénario :
Décision : ❌ Ne pas utiliser CQRS (ou utiliser Niveau 1 seulement)
// Modèle unique, cohérence forte
interface AccountRepositoryInterface
{
public function save(Account $account): void;
public function findById(AccountId $id): ?Account;
// Même BD, cohérence immédiate
}
Raison : Cohérence forte requise, cohérence éventuelle inacceptable.
Chercher :
// Diviser repository en écriture + lecture
// Avant :
interface UserRepositoryInterface { /* toutes méthodes */ }
// Après :
interface UserRepositoryInterface { /* méthodes écriture */ }
interface UserQueryInterface { /* méthodes lecture */ }
Bénéfice : Séparation logique, risque faible.
// Interface lecture utilise SQL brut au lieu ORM
class SqlUserQuery implements UserQueryInterface
{
public function findAll(): array
{
// SQL optimisé avec cache
return $this->cache->remember('users.all', function() {
return $this->connection->fetchAllAssociative('SELECT ...');
});
}
}
Seulement si :
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'12px'}}}%%
graph TD
Start[Besoin séparer lectures/écritures?] --> Q1{CRUD simple?}
Q1 -->|Oui| NoCQRS[❌ NE PAS utiliser CQRS<br/>Repository unique convient]
Q1 -->|Non| Q2{Ratio lecture/écriture élevé?<br/>90%+ lectures?}
Q2 -->|Non| Q3{Reporting complexe?}
Q2 -->|Oui| Level1[✅ Utiliser CQRS Niveau 1<br/>Interfaces séparées, même BD]
Q3 -->|Non| Q4{Cohérence forte<br/>requise partout?}
Q3 -->|Oui| Level1
Q4 -->|Oui| NoCQRS
Q4 -->|Non| Q5{Goulot performance<br/>en lecture?}
Q5 -->|Non| NoCQRS
Q5 -->|Oui| Q6{Besoin BD lecture<br/>séparée?}
Q6 -->|Non| Level2[✅ Utiliser CQRS Niveau 2<br/>Modèles séparés, même BD]
Q6 -->|Oui| Level3[✅ Utiliser CQRS Niveau 3<br/>Bases de données séparées]
style NoCQRS fill:#FFCDD2,stroke:#C62828,stroke-width:2px
style Level1 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
style Level2 fill:#FFF9C4,stroke:#F57F17,stroke-width:2px
style Level3 fill:#B3E5FC,stroke:#0277BD,stroke-width:2px
N'utilisez pas CQRS par défaut. Ajoutez-le quand vous avez un problème de performance prouvé ou des exigences de lecture complexes.
Suivant : Guide Injection de Dépendances →
How can I help you explore Laravel packages today?