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 like | Bound from |
|---|---|---|
| 1 | a type with a Collector declared in this Frond (user?: User) | 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; absent (unless optional) or not a number/boolean → VALIDATION_FAILED |
| 4 | anything 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?: Userbinds directly, andtype CurrentUser = User | undefined; user: CurrentUserbinds to the same collector. - The facade 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.
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' });
}
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 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 isonHand(sku)on the handler, checked by TypeScript, and it always answers a promise: the call may cross a network. 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 remote stand-in. Declaring
stockinfronds: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.