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.

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 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.
Built with Fougere and Nuxt, hosted on GitHub Pages.