Claude-skill-registry CQRS Command Query Generator
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".
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/cqrs-command-query" ~/.claude/skills/majiayu000-claude-skill-registry-cqrs-command-query-generator && rm -rf "$T"
skills/data/cqrs-command-query/SKILL.mdCQRS 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
,UpdateSubscriptionDeleteMember - 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
,ListClubsSearchMembers - 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
club-managementCommands :
: Créer un nouveau clubcreate-club
: Mettre à jour les informations d'un clubupdate-club
: Supprimer un clubdelete-club
: Souscrire à un plan d'abonnementsubscribe-to-plan
: Upgrader un plan d'abonnementupgrade-subscription
: Générer une invitationgenerate-invitation
: Accepter une invitationaccept-invitation
: Retirer un membreremove-member
: Changer de clubchange-club
Queries :
: Récupérer les détails d'un clubget-club
: Lister les clubs (avec pagination)list-clubs
: Récupérer le statut d'abonnementget-subscription
: Lister les plans disponibleslist-subscription-plans
: Valider une invitationvalidate-invitation
: Lister les membres d'un clublist-members
Read Models :
: Vue détaillée d'un clubClubDetailReadModel
: Vue liste des clubsClubListReadModel
: Statut d'abonnementSubscriptionStatusReadModel
: Liste des membresMemberListReadModel
Référence :
volley-app-backend/src/club-management/application/
🚨 Erreurs Courantes à Éviter
-
❌ Command qui retourne un Read Model
- ✅ FAIRE : Command retourne
, puis Query séparée pour récupérer le Read Model{ id: string } - ❌ 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 :
(séparé en Query + Command)GetAndMarkAsReadQuery
-
❌ Logique métier dans le Handler
- ✅ FAIRE :
(logique dans l'entité)club.upgrade(newPlan) - ❌ 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.