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

/** Typed alias — DI resolves by TYPE name, and `AuthorOrm` is the container's key. */
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));
  }

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

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 an ORM can be injected.

Application across surfaces

A computed field is added to the entity's output on all four surfaces: the envelope (useQuery, useCommand, invoke), the REST catch-all, a standalone REST 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.