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 } from '@fougere/core';
import type { EntityOrm } from '@fougere/core';
import Post from '../entities/Post.js';
import Author from '../entities/Author.js';

/** Alias typé — la DI résout par nom de TYPE, et `AuthorOrm` est la clé du container. */
type AuthorOrm = EntityOrm<Author>;

export default class PostPresenter extends Presenter(Post) {
  constructor(private authorOrm: AuthorOrm) { 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 ids = [...new Set(posts.map((p) => p.authorId))];
    const authors = await Promise.all(ids.map((id) => this.authorOrm.findById(id)));
    const byId = new Map(authors.filter(Boolean).map((a) => [a!.id, a!.name]));
    return posts.map((post) => byId.get(post.authorId) ?? '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 ORM, puissent être injectées.

Application aux différentes surfaces

Un champ calculé est ajouté à la sortie de l'entité sur les quatre surfaces : l'enveloppe (useQuery, useCommand, invoke), le catch-all REST, un hôte REST 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.