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
boundaryaxis; 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
findByIdin each iteration still produces N reads. - No silent failures. An error in a computed field produces
INTERNAL_ERRORand names the affected field.
Next: Collectors — resolving parameters by type.