Back to skills
SkillHub ClubShip Full StackFull Stack

cqrs-command-query

Imported from https://github.com/RomualdP/hoki.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
0
Hot score
74
Updated
March 20, 2026
Overall rating
C2.8
Composite score
2.8
Best-practice grade
D50.4

Install command

npx @skill-hub/cli install romualdp-hoki-cqrs-command-query

Repository

RomualdP/hoki

Skill path: .claude/skills/cqrs-command-query

Imported from https://github.com/RomualdP/hoki.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: RomualdP.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install cqrs-command-query into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/RomualdP/hoki before adding cqrs-command-query to shared team environments
  • Use cqrs-command-query for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
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 :

1. **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)

2. **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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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;
  }[];
}
```

```typescript
// application/read-models/club-list.read-model.ts

export interface ClubListReadModel {
  id: string;
  name: string;
  description: string | null;
  membersCount: number;
  createdAt: Date;
}
```

```typescript
// 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

```typescript
// 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

```typescript
// 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 club
- `update-club` : Mettre à jour les informations d'un club
- `delete-club` : Supprimer un club
- `subscribe-to-plan` : Souscrire à un plan d'abonnement
- `upgrade-subscription` : Upgrader un plan d'abonnement
- `generate-invitation` : Générer une invitation
- `accept-invitation` : Accepter une invitation
- `remove-member` : Retirer un membre
- `change-club` : Changer de club

**Queries** :
- `get-club` : Récupérer les détails d'un club
- `list-clubs` : Lister les clubs (avec pagination)
- `get-subscription` : Récupérer le statut d'abonnement
- `list-subscription-plans` : Lister les plans disponibles
- `validate-invitation` : Valider une invitation
- `list-members` : Lister les membres d'un club

**Read Models** :
- `ClubDetailReadModel` : Vue détaillée d'un club
- `ClubListReadModel` : Vue liste des clubs
- `SubscriptionStatusReadModel` : Statut d'abonnement
- `MemberListReadModel` : Liste des membres

Référence : `volley-app-backend/src/club-management/application/`

## 🚨 Erreurs Courantes à Éviter

1. ❌ **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

2. ❌ **Query qui modifie l'état**
   - ✅ FAIRE : Query lit uniquement, jamais de modification
   - ❌ NE PAS FAIRE : `GetAndMarkAsReadQuery` (séparé en Query + Command)

3. ❌ **Logique métier dans le Handler**
   - ✅ FAIRE : `club.upgrade(newPlan)` (logique dans l'entité)
   - ❌ NE PAS FAIRE : Validation métier dans le handler

4. ❌ **Handler qui appelle Prisma directement**
   - ✅ FAIRE : `await this.clubRepository.create(club)`
   - ❌ NE PAS FAIRE : `await this.prisma.club.create(...)`

5. ❌ **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.
cqrs-command-query | SkillHub