| name | CQRS Command Query Generator |
| description | Génère des Commands, Queries et Handlers suivant le pattern CQRS (Command Query Responsibility Segregation). À utiliser lors de la création de use cases, commands, queries, handlers, read models, ou quand l'utilisateur mentionne "CQRS", "command", "query", "handler", "use case", "read model". |
| allowed-tools | Read, Write, Edit, Glob, Grep, Bash |
CQRS Command Query Generator
🎯 Mission
Créer des Commands et Queries suivant le pattern CQRS pour séparer les opérations d'écriture (commands) des opérations de lecture (queries) dans l'Application Layer.
📚 Philosophie CQRS (Command Query Responsibility Segregation)
Principe Fondamental
CQRS sépare les responsabilités en deux types d'opérations :
Commands (Écritures) : Modifient l'état du système
- Create, Update, Delete
- Retournent un ID ou un booléen de succès
- Peuvent échouer (validation, business rules)
Queries (Lectures) : Lisent l'état du système
- Get, List, Search
- Retournent des Read Models (DTOs optimisés)
- Ne modifient JAMAIS l'état
Avantages
- ✅ Séparation claire : Écritures vs Lectures
- ✅ Optimisation indépendante : Read Models optimisés pour l'UI
- ✅ Scalabilité : Possibilité de scaler reads et writes séparément
- ✅ Payloads minimaux : Commands retournent juste un ID
- ✅ Maintenabilité : Ajout de nouvelles opérations sans affecter l'existant
Architecture CQRS dans le Projet
application/
├── commands/ # Write operations
│ └── create-club/
│ ├── create-club.command.ts # Command DTO
│ ├── create-club.handler.ts # Command Handler
│ └── create-club.spec.ts # Tests
├── queries/ # Read operations
│ └── get-club/
│ ├── get-club.query.ts # Query DTO
│ ├── get-club.handler.ts # Query Handler
│ └── get-club.spec.ts # Tests
└── read-models/ # Optimized DTOs for UI
├── club-detail.read-model.ts
├── club-list.read-model.ts
└── index.ts
🖊️ Commands (Write Operations)
Qu'est-ce qu'un Command ?
Un Command représente l'intention de l'utilisateur de modifier l'état du système.
Caractéristiques :
- Nommé avec un verbe d'action :
CreateClub,UpdateSubscription,DeleteMember - Contient toutes les données nécessaires à l'opération
- Validé avec class-validator
- Immuable (DTO, pas de setters)
- Co-localisé avec son handler dans le même dossier
Template Command
// application/commands/create-club/create-club.command.ts
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
export class CreateClubCommand {
@IsString()
@IsNotEmpty()
@MaxLength(100)
readonly name: string;
@IsString()
@IsOptional()
@MaxLength(500)
readonly description?: string;
@IsString()
@IsNotEmpty()
readonly userId: string; // ID de l'utilisateur qui crée le club
constructor(name: string, description: string | undefined, userId: string) {
this.name = name;
this.description = description;
this.userId = userId;
}
}
Template Command Handler
// application/commands/create-club/create-club.handler.ts
import { Injectable, Inject } from '@nestjs/common';
import { CreateClubCommand } from './create-club.command';
import { IClubRepository, CLUB_REPOSITORY } from '../../domain/repositories/club.repository.interface';
import { Club } from '../../domain/entities/club.entity';
import { ClubName } from '../../domain/value-objects/club-name.vo';
@Injectable()
export class CreateClubHandler {
constructor(
@Inject(CLUB_REPOSITORY)
private readonly clubRepository: IClubRepository,
) {}
async execute(command: CreateClubCommand): Promise<string> {
// 1. Créer l'entité domain avec la logique métier
const clubName = ClubName.create(command.name);
const club = Club.create(
clubName,
command.description,
command.userId,
);
// 2. Persister via le repository
const savedClub = await this.clubRepository.create(club);
// 3. Retourner UNIQUEMENT l'ID (payload minimal)
return savedClub.getId();
}
}
Règles pour les Commands
- ✅ Un Command = Une responsabilité unique
- ✅ Validation avec class-validator
- ✅ Retourne un ID (string) ou void
- ✅ Orchestre les entités domain (pas de logique métier)
- ✅ Gère les erreurs métier (throw domain exceptions)
- ❌ JAMAIS de logique métier (celle-ci est dans le Domain)
- ❌ JAMAIS de retour de Read Model (utiliser une Query après)
- ❌ JAMAIS d'accès direct à Prisma (utiliser le repository)
Exemples de Commands
// Write operations (modifient l'état)
CreateClubCommand
UpdateClubCommand
DeleteClubCommand
SubscribeToPlanCommand
UpgradeSubscriptionCommand
GenerateInvitationCommand
AcceptInvitationCommand
RemoveMemberCommand
ChangeClubCommand
📖 Queries (Read Operations)
Qu'est-ce qu'une Query ?
Une Query représente l'intention de l'utilisateur de lire des données sans modifier l'état.
Caractéristiques :
- Nommée avec l'intention de lecture :
GetClub,ListClubs,SearchMembers - Contient les paramètres de filtrage (pagination, sorting, filtering)
- Validée avec class-validator
- Immuable (DTO)
- Co-localisée avec son handler
Template Query
// application/queries/list-clubs/list-clubs.query.ts
import { IsOptional, IsNumber, Min, Max, IsString } from 'class-validator';
export class ListClubsQuery {
@IsOptional()
@IsNumber()
@Min(1)
readonly page?: number = 1;
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
readonly limit?: number = 10;
@IsOptional()
@IsString()
readonly search?: string;
@IsOptional()
@IsString()
readonly userId?: string; // Filtrer par utilisateur
constructor(page?: number, limit?: number, search?: string, userId?: string) {
this.page = page ?? 1;
this.limit = limit ?? 10;
this.search = search;
this.userId = userId;
}
}
Template Query Handler
// application/queries/list-clubs/list-clubs.handler.ts
import { Injectable, Inject } from '@nestjs/common';
import { ListClubsQuery } from './list-clubs.query';
import { IClubRepository, CLUB_REPOSITORY } from '../../domain/repositories/club.repository.interface';
import { ClubListReadModel } from '../../read-models/club-list.read-model';
import { PaginatedResult } from '../../../shared/types/paginated-result';
@Injectable()
export class ListClubsHandler {
constructor(
@Inject(CLUB_REPOSITORY)
private readonly clubRepository: IClubRepository,
) {}
async execute(query: ListClubsQuery): Promise<PaginatedResult<ClubListReadModel>> {
// 1. Récupérer les entités domain via le repository
const result = await this.clubRepository.findAll({
page: query.page,
limit: query.limit,
search: query.search,
userId: query.userId,
});
// 2. Transformer les entités en Read Models (optimisés pour l'UI)
const data = result.data.map(club => this.toReadModel(club));
// 3. Retourner les Read Models avec métadonnées de pagination
return {
data,
meta: {
page: query.page,
limit: query.limit,
total: result.total,
totalPages: Math.ceil(result.total / query.limit),
},
};
}
private toReadModel(club: Club): ClubListReadModel {
return {
id: club.getId(),
name: club.getName().getValue(),
description: club.getDescription(),
membersCount: club.getMembersCount(),
createdAt: club.getCreatedAt(),
};
}
}
Règles pour les Queries
- ✅ Une Query = Une intention de lecture
- ✅ Retourne des Read Models (JAMAIS les entités domain)
- ✅ Transforme Domain Entities → Read Models
- ✅ Optimise pour l'UI (sélection des champs pertinents)
- ✅ Supporte pagination, filtrage, sorting
- ❌ JAMAIS de modification d'état
- ❌ JAMAIS de retour des entités domain brutes
- ❌ JAMAIS de logique métier
Exemples de Queries
// Read operations (ne modifient PAS l'état)
GetClubQuery
ListClubsQuery
GetSubscriptionQuery
ValidateInvitationQuery
ListMembersQuery
SearchClubsQuery
📊 Read Models (Optimized DTOs)
Qu'est-ce qu'un Read Model ?
Un Read Model est un DTO optimisé pour une vue spécifique de l'UI.
Caractéristiques :
- Séparé des entités domain
- Optimisé pour une utilisation spécifique (liste, détail, card, etc.)
- Peut agréger des données de plusieurs entités
- Plain TypeScript interface (pas de validation)
- Nommé avec le suffixe
ReadModel
Template Read Model
// application/read-models/club-detail.read-model.ts
export interface ClubDetailReadModel {
id: string;
name: string;
description: string | null;
createdAt: Date;
// Owner info
owner: {
id: string;
name: string;
email: string;
};
// Subscription info (agrégation)
subscription: {
plan: string;
status: string;
maxTeams: number;
currentTeamsCount: number;
};
// Members count
membersCount: number;
// Teams info
teams: {
id: string;
name: string;
category: string;
}[];
}
// application/read-models/club-list.read-model.ts
export interface ClubListReadModel {
id: string;
name: string;
description: string | null;
membersCount: number;
createdAt: Date;
}
// application/read-models/index.ts
export * from './club-detail.read-model';
export * from './club-list.read-model';
export * from './subscription-status.read-model';
export * from './member-list.read-model';
Règles pour les Read Models
- ✅ Une Read Model par vue UI spécifique
- ✅ Sélection des champs pertinents uniquement
- ✅ Agrégation de données de plusieurs entités si nécessaire
- ✅ Types primitifs (string, number, boolean, Date)
- ✅ Nested objects si nécessaire pour l'UI
- ❌ JAMAIS de méthodes (pure data)
- ❌ JAMAIS de validation decorators (class-validator)
- ❌ JAMAIS de logique métier
🔗 Intégration avec les Controllers
Comment utiliser Commands et Queries dans les Controllers
// presentation/controllers/clubs.controller.ts
import { Controller, Post, Get, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CreateClubCommand } from '../../application/commands/create-club/create-club.command';
import { CreateClubHandler } from '../../application/commands/create-club/create-club.handler';
import { UpdateClubCommand } from '../../application/commands/update-club/update-club.command';
import { UpdateClubHandler } from '../../application/commands/update-club/update-club.handler';
import { GetClubQuery } from '../../application/queries/get-club/get-club.query';
import { GetClubHandler } from '../../application/queries/get-club/get-club.handler';
import { ListClubsQuery } from '../../application/queries/list-clubs/list-clubs.query';
import { ListClubsHandler } from '../../application/queries/list-clubs/list-clubs.handler';
@Controller('clubs')
@UseGuards(JwtAuthGuard)
export class ClubsController {
constructor(
// Inject handlers (NOT use cases)
private readonly createClubHandler: CreateClubHandler,
private readonly updateClubHandler: UpdateClubHandler,
private readonly getClubHandler: GetClubHandler,
private readonly listClubsHandler: ListClubsHandler,
) {}
// Command - Retourne un ID uniquement
@Post()
async create(@Body() command: CreateClubCommand) {
const id = await this.createClubHandler.execute(command);
return { id }; // Payload minimal
}
// Command - Retourne un ID uniquement
@Put(':id')
async update(@Param('id') id: string, @Body() command: UpdateClubCommand) {
const updatedId = await this.updateClubHandler.execute(command);
return { id: updatedId };
}
// Query - Retourne un Read Model
@Get(':id')
async findOne(@Param('id') id: string) {
const query = new GetClubQuery(id);
return this.getClubHandler.execute(query); // Read Model
}
// Query - Retourne une liste de Read Models avec pagination
@Get()
async findAll(@Query() params: any) {
const query = new ListClubsQuery(
params.page,
params.limit,
params.search,
params.userId,
);
return this.listClubsHandler.execute(query); // PaginatedResult<ReadModel>
}
}
Règles pour l'intégration Controller
- ✅ Injecter les Handlers (pas les use cases)
- ✅ Commands retournent
{ id: string } - ✅ Queries retournent Read Models directement
- ✅ Validation automatique via class-validator (NestJS)
- ❌ JAMAIS de logique métier dans le controller
- ❌ JAMAIS de mapping manuel (le handler s'en charge)
🔧 Module Configuration
Enregistrer les Handlers comme Providers
// club-management.module.ts
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
// Controllers
import { ClubsController } from './presentation/controllers/clubs.controller';
// Command Handlers
import { CreateClubHandler } from './application/commands/create-club/create-club.handler';
import { UpdateClubHandler } from './application/commands/update-club/update-club.handler';
import { DeleteClubHandler } from './application/commands/delete-club/delete-club.handler';
// Query Handlers
import { GetClubHandler } from './application/queries/get-club/get-club.handler';
import { ListClubsHandler } from './application/queries/list-clubs/list-clubs.handler';
// Repositories
import { ClubRepository } from './infrastructure/persistence/repositories/club.repository';
import { CLUB_REPOSITORY } from './domain/repositories/club.repository.interface';
@Module({
imports: [PrismaModule],
controllers: [ClubsController],
providers: [
// Repository binding
{
provide: CLUB_REPOSITORY,
useClass: ClubRepository,
},
// Command Handlers
CreateClubHandler,
UpdateClubHandler,
DeleteClubHandler,
// Query Handlers
GetClubHandler,
ListClubsHandler,
],
exports: [
CLUB_REPOSITORY,
],
})
export class ClubManagementModule {}
✅ Checklist CQRS
Commands
- Command nommé avec un verbe d'action (CreateX, UpdateX, DeleteX)
- DTO validé avec class-validator
- Handler orchestre les entités domain
- Retourne un ID (string) ou void
- Pas de logique métier dans le handler
- Co-localisé avec son handler
Queries
- Query nommée avec intention de lecture (GetX, ListX, SearchX)
- Supporte pagination/filtrage si liste
- Handler transforme Domain Entities → Read Models
- Retourne Read Models (pas les entités brutes)
- Pas de modification d'état
- Co-localisée avec son handler
Read Models
- Interface TypeScript (pas de class)
- Optimisé pour une vue UI spécifique
- Champs pertinents uniquement
- Peut agréger plusieurs entités
- Pas de validation decorators
- Exporté via barrel (index.ts)
Handlers
- Injectent les repository interfaces (pas les implémentations)
- Gèrent les erreurs métier
- Tests unitaires présents
- Un handler par command/query
🎓 Exemples Concrets du Projet
Bounded Context club-management
Commands :
create-club: Créer un nouveau clubupdate-club: Mettre à jour les informations d'un clubdelete-club: Supprimer un clubsubscribe-to-plan: Souscrire à un plan d'abonnementupgrade-subscription: Upgrader un plan d'abonnementgenerate-invitation: Générer une invitationaccept-invitation: Accepter une invitationremove-member: Retirer un membrechange-club: Changer de club
Queries :
get-club: Récupérer les détails d'un clublist-clubs: Lister les clubs (avec pagination)get-subscription: Récupérer le statut d'abonnementlist-subscription-plans: Lister les plans disponiblesvalidate-invitation: Valider une invitationlist-members: Lister les membres d'un club
Read Models :
ClubDetailReadModel: Vue détaillée d'un clubClubListReadModel: Vue liste des clubsSubscriptionStatusReadModel: Statut d'abonnementMemberListReadModel: Liste des membres
Référence : volley-app-backend/src/club-management/application/
🚨 Erreurs Courantes à Éviter
❌ Command qui retourne un Read Model
- ✅ FAIRE : Command retourne
{ id: string }, puis Query séparée pour récupérer le Read Model - ❌ NE PAS FAIRE : Command retourne l'entité complète ou le Read Model
- ✅ FAIRE : Command retourne
❌ Query qui modifie l'état
- ✅ FAIRE : Query lit uniquement, jamais de modification
- ❌ NE PAS FAIRE :
GetAndMarkAsReadQuery(séparé en Query + Command)
❌ Logique métier dans le Handler
- ✅ FAIRE :
club.upgrade(newPlan)(logique dans l'entité) - ❌ NE PAS FAIRE : Validation métier dans le handler
- ✅ FAIRE :
❌ Handler qui appelle Prisma directement
- ✅ FAIRE :
await this.clubRepository.create(club) - ❌ NE PAS FAIRE :
await this.prisma.club.create(...)
- ✅ FAIRE :
❌ Read Model = Domain Entity
- ✅ FAIRE : Read Model optimisé pour l'UI, différent de l'entité
- ❌ NE PAS FAIRE : Retourner l'entité domain brute au client
📚 Skills Complémentaires
Pour aller plus loin :
- ddd-bounded-context : Architecture DDD complète avec bounded contexts
- ddd-testing : Standards de tests pour Commands/Queries/Handlers
- prisma-mapper : Patterns de mappers Domain ↔ Prisma
Rappel : CQRS sépare les Écritures (Commands) des Lectures (Queries) pour une architecture plus claire, scalable et maintenable.