Write the domain. Everything else is a projection of it — including the wire.
One class declares the business object. From it come the TypeScript type, the validator, the SQL table, the form contract and the API surface — and because the declaration is JSON on the wire, the domain can move to its own process, or to another language, while the code calling it does not change.
// fronds/blog/entities/Post.ts — this project's own blog, trimmed of three fields
export default class Post extends entity({
id: primary(),
title: text({ min: 1, max: 160 }),
body: optional(text()),
authorId: readOnly(ref(User)), // a User of another frond
createdAt: created(),
status: readOnly(oneOf('draft', 'published', { default: 'draft' })),
publishedAt: readOnly(optional(date())),
}) {}readOnly is not a note about intent: it removes the field from what a client may ever
send, so publishing cannot be a field write — it has to be an operation.
ref reaches into another frond. Both share one database today, so a foreign key holds it
and costs nothing; give the blog its own database and no key can — two of them share no
constraint — so the write reads the row instead, and refuses the same insert. What neither
can reach is the third answer, and the boot names it rather than letting you find out.
One field changes, and there is one place to read. The table, the validator, the GraphQL type and the form contract are derived from it, so they cannot disagree with it — and a declaration the framework can prove wrong is refused at boot, naming it. A diff stays reviewable whoever wrote it: you, a colleague, or an agent.
npm create fougere shop --frond blog --app nuxt
cd shop && pnpm install && pnpm dev # :3000You now have a running app: the table created, the form contract, the REST and GraphQL
surfaces, and pages calling operations through useQuery / useCommand. Nothing above
was generated into a file you have to keep.
An entity is a Standard Schema, so it is accepted wherever
one is — tRPC, Hono, TanStack Form, and the server frameworks adopting the spec for
route-level validation. One npm i @fougere/schema, no adapter package, nothing else of
Fougere in your app.
export class PostDraft extends Post.pick('title', 'summary', 'body') {}
PostDraft['~standard'].validate({ title: '' }); // { issues: [{ message, path: [{ key: 'title' }] }] }Be clear about what crosses: the validator, and only the validator. The other three axes stay
home — no table, no GraphQL type, no form contract. The entity is the piece that fits
through the hole; the reason to come back for the rest is getFields().
| Validation | the same validator in the browser and at the façade — unknown keys refused |
| Storage | the SQL table and additive schema sync |
| Forms | useFormFor(Post) — fields, rules, per-field error mapping |
| API surface | post.list, post.create, post.publish… |
| GraphQL · REST | the types, the inputs, the routes — from the same operations |
| Types | the class is the type |
No codegen step, no dist/generated, no watcher. The declaration is the artefact.
The interesting part is never update(). It is the transition, and a transition has a
validator — the only code on this page Fougere does not derive.
// fronds/blog/handlers/PostHandler.ts
export class PostCard extends Post.pick('id', 'slug', 'title', 'summary', 'authorName', 'publishedAt') {}
export default class PostHandler extends Crud(Post, { list: PostCard }) {
/** Validate: the author, a draft, a body worth publishing. Realize: stamp the pair. */
async publish(id: string, user?: User): Promise<Post> {
const author = requireUser(user, 'publish');
const post = await requireOwn(this.storage, id, author, 'publish');
if (post.status === 'published') {
throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published', entity: 'post', operation: 'publish' });
}
return this.storage.update(id, { status: 'published', publishedAt: new Date() });
}
}user?: User is the injection: the signature is matched by type against the
collector that resolves the session. No decorator, no container lookup to write.
A Frond is a domain — its entities, handlers, collectors, seeds. Where it runs is one line, and it is the only line that changes:
// fougere.config.ts
remotes: { blog: 'http://blog-node:4100' }, // delete this line → same app, in-processIn-process, a call is direct memory execution, not a loopback request. Split, a host going
down is a typed 503 in your pages, and they recover when it returns. The far side does
not have to be TypeScript: demos/rust-frond is a domain written in
Rust whose rules — not just its types — are enforced by the TypeScript validator.
0.5.0-alpha.0, published under latest and alpha. The version is the whole promise: the
surface can still move. Seen running, not planned — a validated draft→publish exercised in a
browser, the split lived daily, identical user code either side through a production
build, and this site is itself a Fougere app.
Known limits, because you would find them anyway: storage is SQLite with additive
auto-DDL, so renames and type changes need an explicit migration; a computed field costs
one read per row unless you name a view. A split receiver binds to loopback by default;
widening it requires signed envelopes, or an explicit allowUnsigned when an upstream mesh
already authenticated the caller. The full list is in
CLAUDE.md, kept honest rather than short.
demos/ isolates one idea per project; nuxt-blog is the flagship.