Presenters

A presenter adds computed fields to an entity's output. Each method defines one field. It receives every row in the response and returns one value per row.

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));
  }

  /** One read for the page, not one per row — which is why the page is the 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');
  }
}

Place the class in the Frond's presenters/ directory. The scan registers it as PostPresenter. Methods may be synchronous or asynchronous, and constructor dependencies are resolved by type.

Why it is not a field on the entity

excerpt is computed from body and does not need to be stored. authorName comes from another entity; computing it at read time avoids duplicating the value when an author is renamed.

Use a presenter when the calculation needs I/O, another entity, or both. It is a class so that dependencies such as a repository can be injected. Direct Storage injection is refused at boot, just as it is for handlers and collectors.

Application across surfaces

A computed field is added to the entity's output on every door: the envelope (useQuery, useCommand, invoke), REST — mounted as the catch-all or as a standalone host — and GraphQL. No additional configuration is required.

const { items } = await useQuery<Post>(Post, 'list');
items[0].excerpt;      // ← present, exactly as in a GraphQL query

Enrichment is now applied in the shared façade. Earlier versions applied it separately in the REST and GraphQL projections, and not in useQuery.

Where it stops: a named view

When an operation names an output view, only fields in that view are returned. A computed field not listed there is excluded:

Crud(Post, { list: PostCard })   // list emits PostCard, and only PostCard

Without a named view, computed fields are added to the entity's output. The boundary axis still applies to the entity's own fields.

What it is not

  • Not a serializer. What may leave at all is the boundary axis; what a given audience sees is a view. A presenter only adds.
  • Not a place for business rules. A computed field is a read. A transition (publish) is an operation, not a field write.
  • Not automatically optimized. A method receives the whole page, which allows reads to be batched. Calling findById in each iteration still produces N reads.
  • No silent failures. An error in a computed field produces INTERNAL_ERROR and names the affected field.

Next: Collectors — resolving parameters by type.

Built with Fougere — this site runs on the framework it documents.