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 like | Bound from |
|---|---|---|
| 1 | a type with a Collector (user: User | null) | the collector's collect(ctx) |
| 2 | ctx: InvocationContext | the whole invocation |
| 3 | a primitive (id: string, page: number) | params[name], then query[name] — coerced to number/boolean |
| 4 | anything else (input: PostDraft) | the request body |
Two consequences of the parser being AST-only (no type checker):
- Spell types out.
user: User | nullbinds;user: CurrentUser(a type alias) is invisible and binds nothing. - The door is what you declare public. A
privateorprotectedmethod 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.#nameworks 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' });
}
oneOflimits the values. A form can render a<select>, the DDL emitsCHECK 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.readOnlyprevents clients from writing the field:boundary.inisclosed, sostatusis 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 Tis exactly the right set. The scan skipsprivateandprotected, so a handler's public methods are its operations — andkeyofexcludes 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
stockinremotes: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.