Handlers

A handler groups the operations of one entity. Crud(Entity) provides the five CRUD operations. Each additional public method becomes an operation.

import { Crud, FougereError, ErrorCode } from '@fougere/core';
import Post from '../entities/Post.js';
import User from '../../user/entities/User.js';

export default class PostHandler extends Crud(Post) {
  constructor(private posts: PostRepository) {
    super(posts);
  }

  /** Public reading: published only. */
  async list(): Promise<PostCard[]> {
    return this.posts.findAllBy({ status: 'published' }) /* … */;
  }

  /** The draft→published transition. */
  async publish(id: Post['id'], user?: User): Promise<Post> {
    if (!user) throw new FougereError({ code: ErrorCode.UNAUTHORIZED, message: 'Sign in to publish' });
    const post = await super.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 super.update(id, { status: 'published', publishedAt: new Date().toISOString() });
  }
}

Fougere does not require a service layer. A handler can contain an operation's logic directly or delegate to a service when the domain needs it.

A Crud(Entity) handler calls the five operations it inherits through super — super.findById(id), super.update(id, input), super.list(options) — the same operations a page or a REST route calls, including whatever the handler redefined. To read the rows as stored, it asks for the entity's repository in the constructor and hands it to super() — the form the boot requires of a Crud handler with a constructor — then calls this.posts.findById(id). The two share a name and are not the same call; the method says which one it means. No handler ever holds a Storage. The repository's gestures — reads: list(options?), findById(id), findBy(criteria), findAllBy(criteria). Writes: create(input), update(id, input), delete(id). output(view) returns the same port scoped to a view's fields.

await this.posts.findBy({ slug });                    // the one row matching
await this.posts.findAllBy({ authorId: user.id });    // every row matching
await this.posts.list({ where: { status: 'published' }, limit: 20, orderBy: 'createdAt' });

The façade validates input before calling the method. The storage then applies lifecycle rules such as automatic values and defaults. The handler's return value is projected and validated before leaving the façade.

Where a named query lives

Storage is a port: generic gestures, no flavour of domain. "The loud readings" is not one of them, so that query ends up spelled at the call site, in the middle of the calculation it feeds. Repository(Entity) gives it a home:

// repositories/ReadingRepository.ts
export default class ReadingRepository extends Repository(Reading) {
  loud(): Promise<Reading[]> {
    return this.findAllBy({ loud: true });
  }
}
// handlers/ReadingHandler.ts — asks the question, never spells the storage
export default class ReadingHandler {
  constructor(private readings: ReadingRepository) {}
  async loud() { return this.readings.loud(); }
}

Every entity has one whether or not you wrote the file, and it is not a facade — so the validator stays here, in the handler. See Repositories.

The four binding rules

Fougere reads your method signature in the project's TypeScript program at boot and binds each parameter from the invocation — in this order:

#Parameter looks likeBound from
1a type with a Collector declared in this Frond (user?: User)the collector's collect(ctx)
2ctx: InvocationContextthe whole invocation
3a primitive (id: string, page: number)params[name], then query[name] — coerced to number/boolean; absent (unless optional) or not a number/boolean → VALIDATION_FAILED
4anything else (input: PostDraft)the request body

The scanner keeps the AST for what you declared and uses the type checker for what the declaration means. In particular:

  • A collector binds only inside its own Frond. Rule 1 reads that Frond's own collectors, so a type it cannot resolve falls to rule 4 and the parameter carries the request body — see A collector does not cross a Frond boundary.
  • Aliases resolve. user?: User binds directly, and type CurrentUser = User | undefined; user: CurrentUser binds to the same collector.
  • The facade is what you declare public. A private or protected method is not an operation: the scan skips it, because TypeScript already has the word for it. A helper the handler names by intent (mustOwn, refuse) therefore stays a helper, inside the class. #name works too.

Canonical absence

The façade normalizes every facade before calling the handler. Absence follows TypeScript, not the transport:

foo?: T          // omitted → undefined
foo: T | null    // null is an explicit domain value and stays null
foo?: T | null   // omitted → undefined; explicit null → null

JSON cannot carry an undefined property, so a remote or browser call omits it. The local path applies the same rule: { foo: undefined } is canonicalized to {}, and reading foo still returns undefined. No adapter may translate an explicit null to undefined. This applies to primitive bindings and to optional fields inside a body.

Input validation

When a parameter's type is a schema class (entity or view), the façade validates the body before calling the method. Invalid input produces VALIDATION_FAILED with per-field details. A partial() view uses patch mode.

A primitive typed by a field of an entity obeys that field. The rules stay where they are declared, in the entity, and the query string is judged against them:

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  → age is the number 21
// ?age=0   → VALIDATION_FAILED, age: 0 is less than 1.

Unknown keys are refused

A key outside the contract produces an error instead of being silently dropped: a body { …, status: 'published' } against a view that does not declare status comes back as VALIDATION_FAILED (status: Unknown field) before the method runs. Accepted input can therefore be passed to the storage without another projection. useFormFor applies the same validation in the browser before the network request.

A state changes through an operation, never through a field write

publish() illustrates an explicit state transition for an order, subscription, or ticket. It combines the following rules:

// the entity — the set of values, and the one it is born with
status: readOnly(oneOf('draft', 'published', { default: 'draft' })),
// the handler — the passage, and the refusal
async publish(id: Post['id']): Promise<Post> {
  const post = await super.findById(id);
  if (post.status === 'published') {
    throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published' });
  }
  return super.update(id, { status: 'published' });
}
  • oneOf limits the values. A form can render a <select>, the DDL emits CHECK status in (…), and GraphQL declares an enum type.
  • { default: 'draft' } is the initial state — a create rule on the lifecycle axis, so nobody supplies it.
  • readOnly prevents clients from writing the field: boundary.in is closed, so status is absent from every input view. A body carrying it is refused as an unknown key.
  • The named operation owns the transition and returns CONFLICT, projected as 409, when the current state prevents it.

The method alone controls the transition order. A direct SQL update, or a handler writing posts.update(id, { status }) through the repository, can bypass it as long as the value satisfies the CHECK. Fougere does not provide a state graph: the field declares possible values and the operation controls transitions between them.

Output view

Crud's second argument selects the output view. Two forms are available:

Crud(Post, { list: PostCard })   // list returns cards, the rest return Post
Crud(Post, PostPublic)           // the whole handler returns PostPublic

With per-operation configuration, the storage returns the whole row and the façade applies the view to the output. Other operations keep their own views.

With a view for the whole handler, the injected storage itself is restricted. A second handler file can therefore define another audience: handlers/PostHandler.ts (full) and handlers/public/PostHandler.ts (restricted).

Reaching another Frond

A Frond does not import another's files: it goes through its facade. Facade<T> is the framework's second port, resolved by type like a repository (Repository<Post>):

import type { Facade } from '@fougere/core';
import type ArticleHandler from '@fronds/stock/handlers/ArticleHandler.js';

export default class CommandeHandler {
  constructor(private articleFacade: Facade<ArticleHandler>) {}

  /** Can this order be served from the shelf? */
  async servable(sku: string): Promise<boolean> {
    const onHand = await this.articleFacade.onHand(sku);
    return onHand > 0;
  }
}

Three things to see:

  • It takes what the handler takes. onHand(sku) on the facade is onHand(sku) on the handler, checked by TypeScript, and it always answers a promise: the call may cross a network.
  • keyof T is exactly the right set. The scan skips private and protected, so a handler's public methods are its operations — and keyof excludes the rest for the same reason.
  • The signature says nothing about where the other Frond runs. The same type resolves the local façade or a remote stand-in. Declaring stock in fronds: does not change this line.

You pass everything, the user included. A collector reads a request, and code holds none: it already has the user, so it hands it over like any other argument. If the called operation takes user?: User, write this.articleFacade.reserve(sku, user); leave it out and the operation receives no user. For the same reason a presenter adds nothing to what a facade answers: you get the rows the handler returned. Collectors and presenters belong to the doors — REST, GraphQL, the page — which receive a request from outside.

Announcing a fact

A call names one recipient; an emission names a subject. Emit<T> is a constructor dependency like any other, and accepting a Fact<T> IS the subscription — no topic, no register call.

See Facts — the resolver, what crosses a process, and what to do when a fact's shape moves.

Writing several entities as one

Storage<T>'s unit is one statement. When two writes must both land or neither, Together<[Account, Ledger]> is the fourth port, read like the other three — its unit is a block:

constructor(private together: Together<[Account, Ledger]>) {}

await this.together.run(async ([accounts, ledger]) => { … });

See Writes that stand or fall as one — the two realizations, and which one you get.

Next: Presenters — computed fields added to an entity's output.

Built with Fougere and Nuxt, hosted on GitHub Pages.