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) {
  /** Public reading: published only. */
  async list(): Promise<PostCard[]> {
    const all = await this.orm.list();
    return all.filter((p) => p.status === 'published') /* … */;
  }

  /** The draft→published transition — an operation, not a field write. */
  async publish(id: string, user: User | null): Promise<Post> {
    if (!user) throw new FougereError({ code: ErrorCode.UNAUTHORIZED, message: 'Sign in to publish', entity: 'post', operation: 'publish' });
    const post = await this.orm.findById(id);
    if (!post) throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `Post '${id}' not found`, entity: 'post', operation: 'publish' });
    if (post.authorId !== user.id) throw new FougereError({ code: ErrorCode.FORBIDDEN, message: 'Only the author can publish', entity: 'post', operation: 'publish' });
    if (post.status === 'published') throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published', entity: 'post', operation: 'publish' });
    return this.orm.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.

this.orm is data access scoped to the entity (EntityOrm). Reads: list(options?), findById(id), findBy(criteria), findAllBy(criteria). Writes: create(input), update(id, input), delete(id). output(view) returns an ORM scoped to a view's fields.

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

The façade validates input before calling the method. The ORM 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

EntityOrm is a port: five 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.orm.findAllBy({ loud: true });
  }
}
// handlers/ReadingHandler.ts — asks the question, never spells the storage
export default class ReadingHandler {
  constructor(private readingRepository: ReadingRepository) {}
  async loud() { return this.readingRepository.loud(); }
}

Write none and you lose nothing. The bootstrap registers a default repository for every entity — the guarded ORM itself — so ReadingRepository resolves whether or not the file exists. Declaring one wins, exactly as a Crud operation redefined in the subclass wins over the prefab's.

It is not a door: a repository has no façade, so nothing it carries is reachable from the wire. The judge stays in the handler, the one place a refusal cannot be walked around.

The four binding rules

Fougere reads your method signature (AST parse at boot) and binds each parameter from the invocation — in this order:

#Parameter looks likeBound from
1a type with a Collector (user: User | null)the collector's collect(ctx)
2ctx: InvocationContextthe whole invocation
3a primitive (id: string, page: number)params[name], then query[name] — coerced to number/boolean
4anything else (input: PostDraft)the request body

Two consequences of the parser being AST-only (no type checker):

  • Spell types out. user: User | null binds; user: CurrentUser (a type alias) is invisible and binds nothing.
  • The door 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.

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.

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 ORM 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: string): Promise<Post> {
  const post = await this.orm.findById(id);
  if (post.status === 'published') {
    throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published',
      entity: 'post', operation: 'publish' });
  }
  return this.orm.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 another handler calling this.orm.update(id, { status }), 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 ORM 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 ORM 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 door. Facade<T> is the framework's second port, read like the first (EntityOrm<Post>):

import type { Facade } from '@fougere/core';
import type ArticleHandler from '@frond/stock/handlers/ArticleHandler';

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

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

Three things to see:

  • It is the door, not the handler. Nobody injects a handler — its methods take positional arguments. The façade takes the invocation: every operation on Facade<T> has the signature (invocation?) => Promise<…>.
  • 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 doublure. Declaring stock in remotes: does not change this line.

What does not travel on its own is identity. The called operation receives the invocation you hand it and nothing else: for a collector on the far side to see the same user, declare ctx: InvocationContext (binding rule 2) and pass it through.

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.

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

Built with Fougere — this site runs on the framework it documents.