Handler

A handler is what your app does. It is a class in handlers/, its public methods are the operations, and their signatures are the contract: what each takes, what it answers, what it refuses. There is no route to write and nothing to register.

// fronds/blog/handlers/PostHandler.ts
import { Repository, FougereError, ErrorCode } from '@fougere/core';
import Post from '../entities/Post.js';
import User from '@fronds/user/entities/User.js';

export default class PostHandler extends Repository<Post> {
  /** The draft → published transition. */
  async publish(id: Post['id'], user: User): Promise<Post> {
    const post = await this.findById(id);
    if (!post) throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `Post '${id}' not found` });
    if (post.authorId !== user.id) throw new FougereError({ code: ErrorCode.FORBIDDEN, message: 'Only the author can publish' });
    if (post.status === 'published') throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published' });

    return this.update(id, { status: 'published', publishedAt: new Date() });
  }
}

What it serves

A handler that extends Repository<Post> serves the five operations of Post's rows — list, findById, create, update, delete — beside each public method it writes: here, publish. What else the rows can do — findBy, findAllBy, findByKeys, upsert — the class may call but never serves.

  • Extending a PostRepository serves what that repository adds, too.
  • To use a query without serving it, ask for the repository instead: constructor(private posts: PostRepository).
  • To serve the five differently — published posts only, a draft visible to its author — do not extend a repository: ask for it, and write the operations you serve. TypeScript refuses an inherited method a different signature.
  • A private or protected method is not an operation. A helper stays a helper.

A handler never holds a Storage; it reaches rows through its repository. TypeScript erases <Post>, so the scan reads it; a frond written by hand says it: { ctor: PostHandler, repository: Post }.

The API checks what arrives before your method runs, the storage fills defaults and stamps, and what your method answers is checked again before it leaves.

Query or command

The name says whether an operation reads or writes. It reads when every verb in its name is a read verb — get, list, find, count, search, has, is… — and writes otherwise: create, pay, publish, and also total or getOrCreate. A query may be cached; a command never is. A read taken for a write still works, which is why the rule leans that way.

When a name does not say it, state it for that one operation:

// fronds/shop/frond.config.ts
export default { operations: { 'cart.total': { kind: 'query' } } };

When a read verb of your domain starts many operations, teach it once: Verbs.term('total', 'query') in the frond's vocabulary/.

How parameters are filled

Fougere reads each method's signature at boot and fills every parameter from the call, in this order:

#The parameter is…Filled from
1a type a collector of this frond answers (user: User)the collector
2ctx: InvocationContextthe whole call
3a primitive (id: string, page: number)the path, then the query string — turned into a number or a boolean
4anything else (input: PostDraft)the request body
  • A collector answers only inside its own frond; elsewhere the type falls to rule 4 — why.
  • Aliases resolve: type CurrentUser = User | undefined; user: CurrentUser binds like user?: User.
  • A parameter without ? that nothing filled is refused before your method runs.

Absence follows TypeScript, not the transport:

foo?: T          // omitted → undefined
foo: T | null    // null is a value of your domain, and stays null
foo?: T | null   // omitted → undefined; null → null

JSON cannot carry undefined, so a call from a browser or another process omits the key; a local call is made to read the same, and no adapter turns null into undefined.

What the API checks

A parameter typed by an entity or a view has its body checked before the method runs; a failure is VALIDATION_FAILED, field by field. A partial() view checks only what was sent.

A primitive typed by a field obeys that field's rules, read from the entity:

class Sample extends entity({
  id: primary(text({ format: 'uuid' })),
  age: number({ integer: true, min: 1 }),
}) {}

async match(sampleId: Sample['id'], age: Sample['age']) { … }
// ?age=21  → the number 21
// ?age=0   → VALIDATION_FAILED, age: 0 is less than 1.

A key the contract does not declare is refused (status: Unknown field) rather than dropped, and a readOnly field a client sends is refused too (status: Read-only). What reaches your method matches the contract exactly, and a form checks the same rules in the browser.

A state changes through an operation

publish() is the shape of every state transition — an order, a subscription, a ticket:

// the entity — the possible values, and the first one
status: readOnly(oneOf('draft', 'published', { default: 'draft' })),
  • oneOf limits the values: a <select> in a form, a CHECK in the table, an enum in GraphQL.
  • default: 'draft' is the state a post is born in; nobody sends it.
  • readOnly keeps clients from writing it: only an operation can.
  • The operation owns the transition, and refuses with CONFLICT when the current state forbids it.

There is no state machine: the field says which values exist, the operation says how one becomes another. A handler writing this.update(id, { status }) elsewhere would skip the rule — the rule lives in the operation, so keep transitions there.

What leaves

An operation answering a view says what leaves; the rest stays on the server, a computed field included:

async cards(): Promise<Page<PostCard>> { return Page.of(await this.list()); }  // one operation
class PostHandler extends Repository<PostPublic> {}                            // a whole handler

The second reads through the view itself. Two handler files can then serve two audiences: handlers/PostHandler.ts and handlers/public/PostHandler.ts.

Refusing

An operation refuses by throwing a FougereError with a code:

throw new FougereError({
  code: ErrorCode.CONFLICT,
  message: `Slug '${slug}' is already taken`,
  // details?: [{ path, message }]   — per field, as VALIDATION_FAILED uses them
  // cause?: unknown
});

The API adds address and operation — where the call was refused — so you never write them. The same error reaches the caller whether the frond is in the process or across the network:

  1. in memory, untouched, for a local call;
  2. as a JSON-RPC error across processes, rebuilt into a FougereError on arrival, details included;
  3. in a page, as error on useQuery and useCommand, and as per-field errors in useFormFor;
  4. on REST, as the matching HTTP status, the whole value in data.

An unreachable process answers SERVICE_UNAVAILABLE. Every code and its status: Error codes. What an operation can refuse is read from the code — through the helpers it calls — and published on its contract, so a page knows it before calling.

The site's blog checks in this order:

author: User               // UNAUTHORIZED — a required parameter, refused before the method runs
→ requireOwn(id, author)   // NOT_FOUND, then FORBIDDEN
→ state checks             // CONFLICT (already published, empty draft)
→ write                    // this.update(…)

To name a query, give it a repository; to reach another frond, ask for its facade.

Put it to work

Built with Fougere and Nuxt, hosted on GitHub Pages.