A computed field
A computed field is added to every answer without being stored: an excerpt cut from the body, an author's name read from another entity. A presenter computes them — one method per field. Each method receives the whole page of rows and returns one value per row, so a read can be done once for the page.
import { Present, type Repository } from '@fougere/core';
import Post from '../entities/Post.js';
import Author from '../entities/Author.js';
export default class PostPresenter extends Present<Post> {
constructor(private authors: Repository<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 services/ directory, where extends Present<Post> says what it
is. It answers Present<Post>. Methods may be synchronous or asynchronous, and constructor dependencies
are resolved by type.
A link adds fields of its own
Present<Post> is a port too. A link in front of the presenter adds its
methods as fields, and the answer carries the fields of the whole chain — on REST, on GraphQL, and
for every audience:
export default class Badges implements Partial<Present<Post>> {
constructor(private inner: Present<Post>) {}
isNew(posts: Post[]): boolean[] {
return posts.map((post) => Date.now() - post.publishedAt.getTime() < 86_400_000);
}
}
A field two classes of one chain compute refuses the boot, naming the field and both classes. Two
links are ordered by ports: { 'Present<Post>': ['Badges', 'Stamps'] }, outside in.
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.
Everywhere a reader goes through a door
A computed field is added to the entity's output on every door: a page (useQuery, useCommand,
invoke), REST and GraphQL. There is nothing else to configure.
A handler calling another frond through Facade<T>
does not get it: code receives the rows the handler returned, and a computed field is for
whoever reads through a door.
const { items } = await useQuery(post, 'list');
items[0].excerpt; // ← present, exactly as in a GraphQL query
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:
async cards(): Promise<Page<PostCard>> // 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.