Home
30 décembre 202410 min de lecture

CQRS pour les boomers

CQRSStannahNest.js
C etait mieux avant.

"CQRS toi même !"

Derrière CQRS (Command Query Responsibility Segregation) se cache un concept assez simple, mais extrêment puissant et utile. L'idée est de séparer les opérations d'écriture (Commands) des opérations de lecture (Queries).

Prenons un exemple du bon vieux temps où on avait pas Internet et où il fallait 2 jours pour charger un jeu sur un Amstrad 464 (en espérant que ca plante pas avant) :

  • Les opérations d'écriture (Commands) : comme quand vous écriviez dans votre carnet d'adresses
  • Les opérations de lecture (Queries) : comme quand vous consultiez votre Minitel (coucou Xavier Niel)

Les Commands

Avant d'entrer dans le code, comprenez bien que les Commands, c'est comme envoyer une lettre recommandée : une intention claire, un destinataire unique, et pas de réponse autre que "c'est bien arrivé" (ou pas).

La structure d'une Command

// commands/create-post.command.ts
export class CreateBlogPostCommand {
  constructor(
    public readonly title: string,
    public readonly content: string,
    public readonly authorId: string,
  ) {}
}

// Le Handler, c'est comme le facteur de La Poste : il sait quoi faire avec votre lettre
@CommandHandler(CreateBlogPostCommand)
export class CreateBlogPostHandler {
  constructor(
    private readonly postRepository: PostRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: CreateBlogPostCommand): Promise<void> {
    // Validation
    if (command.title.includes('URGENT!!!')) {
      throw new Error('Rien n est urgent à moins que ça ne le soit vraiment, petit scarabé');
    }

    // Oh ? Mais de quoi donc est ce que ceci ?
    const post = new BlogPost({
      title: command.title,
      content: command.content.replace('👍', ':-)'),
      authorId: command.authorId,
    });

    // Vraiment ? Vous ne voyez pas ?
    await this.postRepository.save(post);
    
    // Petit event au passage, pour le swag
    this.eventBus.publish(new BlogPostCreatedEvent(post.id));
  }
}

On a :

  • La Command : les données pures (comme une carte perforée, bon ok j'abuse avec les références du siècle dernier)
  • Le Handler : la logique de traitement
  • Les Events : la notification du succès

Les Queries

Les Queries, c'est comme consulter l'annuaire (oui vous savez, le gros truc blanc qu'on recevait chaque année gratuitement par les PTT) : vous demandez une info, vous la recevez. Pas de modification, pas d'effet de bord, pas de surprise.

Structure d'une Query

// queries/get-posts.query.ts
export class GetBlogPostsQuery {
  constructor(
    public readonly page: number = 1,
    public readonly limit: number = 10, 
  ) {}
}

// Encore un handler ! Incroyable ! 
@QueryHandler(GetBlogPostsQuery)
export class GetBlogPostsHandler {
  constructor(
    private readonly postReadModel: PostReadModel, // WHAT ?!
  ) {}

  async execute(query: GetBlogPostsQuery): Promise<BlogPostDTO[]> {
    const posts = await this.postReadModel.findAll({
      skip: (query.page - 1) * query.limit,
      take: query.limit,
      orderBy: { createdAt: 'DESC' },
    });

    return posts.map(post => ({
      ...post,
      content: post.content.replace('🔥', '*HOT*'), // Pour l'accessibilité, vous voyez le truc quoi 
    }));
  }
}

ReadModel? Quelle est cette sorcellerie ?

  • C'est comme avoir un cache des pages jaunes : optimisé pour la lecture
  • Dénormalisé
  • Peut être sur une base différente (MongoDB pour les lectures, PostgreSQL pour les écritures)
  • On a le potentiel pour appuyer là où ca fait mal : des ressources plus importantes en lecture qu'en écriture

On revient sur ReadModel un peu plus bas. Tenez le coup.

Le Controller

Le Controller, c'est comme le standardiste moustachu de votre entreprise en 1990 : il reçoit les appels et les redirige vers le bon service, avec une gitane sans filtre calée au coin du bec.

Structure d'un Controller

@Controller('blog')
export class BlogController {
  constructor(
    private readonly commandBus: CommandBus,
    private readonly queryBus: QueryBus,
  ) {}

  // Par définition, j'ai envie de dire : POST = Command
  @Post()
  async createPost(@Body() dto: CreatePostDTO): Promise<void> {
    try {
      // Validation basique, comme les vérifications de formulaire en jQuery
      if (!dto.title?.trim()) {
        throw new BadRequestException('Le titre est obligatoire, même s il fait 1 caractère');
      }

      // Envoi de la commande
      await this.commandBus.execute(
        new CreateBlogPostCommand(dto.title, dto.content, dto.authorId)
      );
    } catch (error) {
      console.error('ERREUR FATALE !!! ON VA TOUS MOUR¨ù*m', error);
      throw error;
    }
  }

  // Par définition, j'ai envie de dire : GET = Query
  @Get()
  async getPosts(
    @Query('page') page: number = 1,
    @Query('limit') limit: number = 10,
  ): Promise<BlogPostDTO[]> {
    if (page < 1) page = 1; // Oui, bon... hein... 
    if (limit > 50) limit = 50; // Protection contre les jeunes qui veulent tout télécharger. On a enterré le respect

    // Envoi de la query
    return this.queryBus.execute(new GetBlogPostsQuery(page, limit));
  }

  @Get('stats')
  async getStats(): Promise<BlogStats> {
    // Pour ceux qui kiffent les graphiques Excel
    return this.queryBus.execute(new GetBlogStatsQuery());
  }
}

Pourquoi cette séparation ?

  • Les Commands partent dans un bus de commands
  • Les Queries partent dans un bus de queries (donc pas le même que les commands)
  • Pas de mélange, pas de confusion, de la séparation, tout ça tout ça

Le Read Model (encore ??)

Nouvelle analogie histoire de bien vous perdre :

Le Read Model, c'est comme avoir une copie scannée de vos documents papier : pas besoin d'aller récupérer l'original à chaque fois

Structure complète d'un Read Model

// infrastructure/read-model/post.read-model.ts
@Injectable()
export class PostReadModel {
  constructor(
    @InjectModel(PostView.name)
    private readonly postViewModel: Model<PostView>,
  ) {}

  async findAll(options: { 
    skip: number; 
    take: number;
    orderBy?: Record<string, 'ASC' | 'DESC'>;
    filter?: Record<string, any>;
  }): Promise<PostView[]> {
    // Construction de la query
    const query = this.postViewModel.find();

    if (options.filter) {
      // Filtrage si besoin
      Object.entries(options.filter).forEach(([key, value]) => {
        query.where(key).equals(value);
      });
    }

    // Requête
    return query
      .skip(options.skip)
      .limit(options.take)
      .sort(options.orderBy || { createdAt: -1 })
      .exec();
  }

  async findOne(id: string): Promise<PostView | null> {
    return this.postViewModel.findOne({ _id: id }).exec();
  }

  // Event handler pour synchroniser le read model
  @EventHandler(BlogPostCreatedEvent)
  async onPostCreated(event: BlogPostCreatedEvent): Promise<void> {
    await this.postViewModel.create({
      id: event.postId,
      title: event.title,
      // Conversion des emojis en emoticons pour les puristes
      content: event.content.replace('😊', ':-)'),
      authorId: event.authorId,
      createdAt: new Date(),
      // Données dénormalisées pour la performance
      authorName: await this.getAuthorName(event.authorId),
      commentCount: 0,
      likeCount: 0,
    });
  }

  // Autres handlers pour maintenir la cohérence
  @EventHandler(BlogPostCommentedEvent)
  async onPostCommented(event: BlogPostCommentedEvent): Promise<void> {
    // Mise à jour des compteurs
    await this.postViewModel.updateOne(
      { _id: event.postId },
      { $inc: { commentCount: 1 } }
    );
  }
}

Pourquoi cette dénormalisation ?

Réponse en trois bullets points impactants qui vont faire de vous un homme riche (désolé je sors de ma lecture de posts LinkedIn quotidienne) :

  • Performance de lecture
  • Pas de joins
  • Scalabilité

L'Event Store

L'Event Store, c'est comme votre vieux carnet où vous notiez tout : chaque modification est enregistrée, datée, signée. Ca peut toujours servir quand vous avez un doute sur la fuite de vos données, même s'il est évidemment très peu plausible que ça arrive un jour. Hein.

Structure complète de l'Event Store

// infrastructure/event-store/event-store.ts
@Injectable()
export class EventStore {
  constructor(
    @InjectModel('Event') private readonly eventModel: Model<Event>,
    private readonly logger: Logger,
  ) {}

  async append(event: DomainEvent): Promise<void> {
    try {
      // Sauvegarde de l'event
      await this.eventModel.create({
        type: event.constructor.name,
        data: event,
        timestamp: new Date(),
        version: await this.getNextVersion(),
      });

      this.logger.log(`Event ${event.constructor.name} sauvegardé`);
    } catch (error) {
      // Log d'erreur 
      this.logger.error('ERREUR CRITIQUE SYSTEME !!! CA VA PETER !!!');
      throw error;
    }
  }

  async getEvents(
    aggregateId?: string, 
    afterVersion?: number
  ): Promise<DomainEvent[]> {
    // Reconstruction de l'historique
    const query: any = {};
    if (aggregateId) query.aggregateId = aggregateId;
    if (afterVersion) query.version = { $gt: afterVersion };

    return this.eventModel
      .find(query)
      .sort({ version: 1 })
      .exec();
  }

  private async getNextVersion(): Promise<number> {
    const lastEvent = await this.eventModel
      .findOne()
      .sort({ version: -1 })
      .exec();

    return (lastEvent?.version || 0) + 1;
  }
}

Pourquoi garder tous ces events ?

  • Audit trail complet
  • Possibilité de "replay" (comme rembobiner une cassette avant de la ramener au vidéo club)
  • Debug facilité
  • Reconstruction possible des états

Conseils pour les anciens qui se lancent en 2025

Commencez petit

  • Ne transformez pas tout votre code d'un coup
  • Commencez par une fonctionnalité non critique
  • Testez, testez, testez
  • Testez
  • Vraiment, testez

Architecturez bien

  • Séparez les couches
  • Nommez clairement les choses
  • Documentez comme si votre vie en dépendait (surtout si vous êtes l'heureux possesseur d'un pacemaker)

Voilà les petits loulous, j'espère que personne n'est tombé de son déambulateur.

En tous cas, croyez-moi, c'est comme passer de la machine à écrire au traitement de texte : au début on râle, et puis on ne peut plus s'en passer.

Contact

Parlons de votre prochain projet !

Email

gregory@babonaux.com

Téléphone

+33 6 50 83 23 05