Presenters

Un presenter ajoute des champs calculés à la sortie d'une entité. Chaque méthode définit un champ. Elle reçoit toutes les lignes de la réponse et renvoie une valeur par ligne.

import { Presenter, type RepositoryOf } from '@fougere/core';
import Post from '../entities/Post.js';
import Author from '../entities/Author.js';

export default class PostPresenter extends Presenter(Post) {
  constructor(private authors: RepositoryOf<Author>) { super(); }

  excerpt(posts: Post[]): string[] {
    return posts.map((post) => post.body.slice(0, 200));
  }

  /** Une lecture pour la page, pas une par ligne — c'est pourquoi la page est l'argument. */
  async authorName(posts: Post[]): Promise<string[]> {
    const authors = await this.authors.findByKeys(posts.map((post) => post.authorId));
    return posts.map((post) => authors.get(post.authorId)?.name ?? 'Anonymous');
  }
}

Placez la classe dans le dossier presenters/ de la Frond. Le scan l'enregistre sous le nom PostPresenter. Les méthodes peuvent être synchrones ou asynchrones, et les dépendances du constructeur sont résolues par type.

Pourquoi ce n'est pas un champ de l'entité

excerpt est calculé depuis body et n'a pas besoin d'être stocké. authorName vient d'une autre entité ; le calculer à la lecture évite de dupliquer cette valeur lors d'un renommage d'auteur.

Utilisez un presenter lorsque le calcul demande des I/O, une autre entité ou les deux. Il s'agit d'une classe afin que ses dépendances, notamment un repository, puissent être injectées. L'injection directe d'Storage est refusée au boot, comme pour les handlers et les collectors.

Application aux différentes surfaces

Un champ calculé est ajouté à la sortie de l'entité sur chaque porte : l'enveloppe (useQuery, useCommand, invoke), REST — monté en catch-all ou en hôte standalone — et GraphQL. Aucune configuration supplémentaire n'est nécessaire.

const { items } = await useQuery<Post>(Post, 'list');
items[0].excerpt;      // ← présent, exactement comme dans une requête GraphQL

L'enrichissement est maintenant appliqué dans la façade commune. Les versions précédentes l'appliquaient séparément dans les projections REST et GraphQL, et pas dans useQuery.

Où ça s'arrête : une vue nommée

Lorsqu'une opération nomme une vue de sortie, seuls les champs de cette vue sont renvoyés. Un champ calculé qui n'y figure pas est exclu :

Crud(Post, { list: PostCard })   // list émet PostCard, et rien que PostCard

Sans vue nommée, les champs calculés s'ajoutent à la sortie de l'entité. L'axe boundary continue de s'appliquer aux champs de l'entité.

Ce que ce n'est pas

  • Pas un sérialiseur. Ce qui peut sortir du tout est l'axe boundary ; ce qu'une audience donnée voit est une vue. Un presenter ne fait qu'ajouter.
  • Pas un endroit pour les règles métier. Un champ calculé est une lecture. Une transition (publish) est une opération, pas une écriture de champ.
  • Pas automatiquement optimisé. Une méthode reçoit toute la page, ce qui permet de regrouper les lectures. Un findById dans chaque itération produira toujours N lectures.
  • Pas d'échec silencieux. Une erreur dans un champ calculé produit INTERNAL_ERROR en indiquant le champ concerné.

Suite : Collectors — résoudre les paramètres par type.

Construit avec Fougere — ce site tourne sur le framework qu'il documente.