Écrivez le domaine. Tout en dérive — jusqu'au process où il tourne.
Une classe déclare l'objet métier : validation, table, formulaire et surfaces en dérivent. Le jour où il doit partir dans son propre process — ou dans un autre langage — le code qui l'appelle ne bouge pas.
Le code ci-dessous est la Frond blog de ce site — pas du pseudocode.
Le modèle : une déclaration, tout se projette
Une entité n'est pas une table. La table, la validation, le formulaire, l'API sont des projections d'une seule déclaration — changez-la, chaque projection suit.
Le schéma — déclaré une fois
class Post extends entity({
id: primary(),
title: text({ min: 1 }),
status: readOnly(oneOf(
'draft', 'published')),
}) {}Post.validate(input)dérivée du shape · embarquée avec la classe
Surface d'API
post.list · post.publish
Table de base de données
auto-DDL → SQLite
Type TypeScript
function render(p: Post)
Contrat de formulaire
useFormFor(Post)
Type GraphQL
type Post { … }
Désignation & DI
useQuery(Post, 'list')
Un noyau, six projections — changez la déclaration, chaque projection suit.
1 Déclarer
Une classe d'entité. Validation, table SQLite, type GraphQL, contrat de formulaire — toutes ses projections.
class Post extends entity({
id: primary(),
slug: text({ min: 1, max: 80 }),
title: text({ min: 1, max: 160 }),
authorId: readOnly(text()),
status: readOnly(oneOf('draft', 'published',
{ default: 'draft' })),
publishedAt: readOnly(optional(date())),
}) {}2 Juger
Des opérations, pas des écritures de champ. readOnly ferme la porte entrante ; le serveur tamponne la paire.
class PostHandler extends Crud(Post) {
async publish(id: string, user: User | null) {
if (!user) throw new FougereError({
code: ErrorCode.UNAUTHORIZED, /* … */ });
// author-only, draft-only — then realize:
return this.orm.update(id, {
status: 'published',
publishedAt: new Date().toISOString(),
});
}
}3 Consommer
La classe importée désigne l'appel. Une command sur Post revalide toutes les queries sur Post.
import Post from '@frond/blog/entities/Post';
const { items } = await useQuery(Post, 'list');
const publish = useCommand(Post, 'publish');
await publish.execute({ params: { id } });
// → every mounted query on Post revalidatesLe gradient
Une Frond tourne in-process ou dans son propre process derrière JSON-RPC 2.0 — avec un code utilisateur identique. Pas de RPC sans voyage : en local, l'appel est une exécution mémoire directe.
remotes: { blog: 'http://127.0.0.1:4100' }— la seule ligne qui change- Les erreurs voyagent intactes : même code, message et détails par champ des deux côtés
- La session atteint les collectors distants — la confiance est intra-topologie
- Host mort → 503 typée dans vos pages ; relance → récupération, app intouchée
Une Frond n'a pas à être en TypeScript
Une Frond n'honore que deux contrats, et les deux sont du JSON : le fil (JSON-RPC 2.0) et la carte (rpc.discover, qui rend ce qu'elle héberge, schémas compris). Aucun des deux ne mentionne TypeScript. demos/rust-frond est un domaine telemetry écrit en Rust — aucune classe d'entité nulle part, la déclaration vit dans src/main.rs.
Le consommateur demande la carte, en rebâtit un schéma vivant, et refuse un payload avant tout réseau. Ces refus sont les quatre axes traversant une frontière de langage : shape EST le JSON Schema, role, lifecycle et boundary voyagent sous x-fougere. Les règles voyagent, pas seulement les types.
$ npx tsx consumer.ts
✗ couleur — Unknown field
✗ celsius — 250 is greater than 80.
✗ checksum — Read-only
✗ label — String is too short (1 < 2).Règles déclarées en Rust, tenues par le juge TypeScript — aucune ligne de TS ne les déclare
Ce que le modèle fait disparaître
Une conséquence visible : sans modèle, toute app redéclare la même forme dans le validateur, la table, l'endpoint et le formulaire — quatre fichiers qui ne doivent jamais diverger. Avec, la déclaration est seule et tout le reste dérive.
// schemas/post.ts — the shape, first time
export const postSchema = z.object({
slug: z.string().min(1).max(80),
title: z.string().min(1).max(160),
});
// server/db/schema.ts — the shape, again
export const posts = sqliteTable('posts', {
slug: text('slug').notNull(),
title: text('title').notNull(),
});
// server/api/posts.post.ts — wired by hand
const body = postSchema.parse(await readBody(event));
// app/components/PostForm.vue — the rules, again
const rules = { title: [required, maxLength(160)] };4 déclarations de la même forme, synchronisées à la main
// fronds/blog/entities/Post.ts — the shape, once
class Post extends entity({
id: primary(),
slug: text({ min: 1, max: 80 }),
title: text({ min: 1, max: 160 }),
}) {}
// Derived from it — nothing to keep in sync:
// validation (browser + façade, same judge)
// SQLite table + additive schema sync
// form contract useFormFor(Post)
// API surface post.create / post.list
// GraphQL type type Post { … }1 déclaration — le reste est dérivé
Ne nous croyez pas — faites compter votre agent
La glue dupliquée est la trace mesurable d'un modèle absent. Collez ce prompt dans l'agent IA qui connaît déjà votre codebase (Claude Code, Cursor…) : il compte les formes redéclarées et le câblage de synchro de votre app — et rapporte les coûts d'adoption avec le même soin.
# Audit: how much schema glue does this repo maintain by hand?
You are auditing THIS repository. Be honest: report the costs
of switching as carefully as the gains.
## Reference model — Fougere, a single-schema TS framework
One class declares a business object once:
class Post extends entity({
id: primary(),
slug: text({ min: 1, max: 80 }),
title: text({ min: 1, max: 160 }),
status: readOnly(oneOf('draft', 'published',
{ default: 'draft' })),
}) {}
Everything derives from it — input validation (the same judge
in the browser and at the API facade, unknown keys refused),
the SQL table (additive auto-DDL; renames, removals and type
changes need an explicit migration), the form contract (fields,
rules, per-field error mapping), the API surface (post.list,
post.create...), GraphQL types, and the TS type (the class IS
the type). Business rules are handler operations, e.g.
publish(id, user), judged server-side. Moving a module to its
own process is one line of config; user code does not change.
Scope today (pre-release): storage is additive auto-DDL over
Kysely. SQLite resolves from its name; Postgres, MySQL and SQL
Server work by handing Fougere the Kysely dialect you built
(setupKysely) — only you have the driver. No search-index
projection; auth via better-auth (credentials + OAuth). Price
the adoption costs against THIS scope, not an imagined one.
If you can fetch the web, ground yourself in the docs first:
- http://localhost/docs — the model in one page
- http://localhost/docs/schema/entities — field vocabulary, the 4 axes
- http://localhost/docs/client/forms — the shared browser/facade judge
- http://localhost/docs/existing-app — the feature-by-feature migration
path (use it to price the adoption cost honestly)
## Measure, in this repo
1. Identify the 3 most-touched business objects. A repo has
no traffic stats — use git churn as the proxy (most-modified
schema/form/handler files over the last ~500 commits),
crossed with the app's main forms and API routes.
2. For each, list every file where its SHAPE is re-declared:
validation schema (Zod/Yup/joi), DB table or migration,
API input/output types, form state and rules, TS
interfaces, API-client types. Quote the paths. If one
object's shape belongs to an external system (legacy API,
search index), audit it anyway and flag it: that is the
unfavorable case, and it belongs in an honest report.
3. Count the lines that exist ONLY to keep those in sync:
parse/serialize, DTO mapping, hand-rolled error
formatting, manual refetch after mutations. Counting
rule: committed codegen output and pass-through wrappers
count; business logic in resolvers or computed fields
does not.
4. Look for one place where two of those declarations
already disagree (a max length, an optional, a nullable).
There usually is one — that drift is the argument. If there
is none here, say so plainly rather than reaching.
## Report
Per object: a table declaration-site → file → lines.
Then two totals, honestly:
- lines deletable under a derive-everything model;
- what adopting Fougere would cost HERE: storage handover
(SQLite auto-DDL today), pre-release status (not on npm
yet), the feature-by-feature migration path, what the
team would have to learn.Où en est Fougere
Pré-version — mais pas une promesse. Chacun de ces points a été vu tourner :
- Les 5 primitives client sont le seul chemin — l'ancien monde a été supprimé, pas remplacé à moitié
- Une feature métier jugée (draft→publish) validée en navigateur, bascule de badge comprise
- Le split est vécu au quotidien : host tué → 503 typé dans les pages ; relancé → récupération
- Code utilisateur identique in-process et split — vérifié jusqu'au build de prod
- Ce site — docs, blog, auth — tourne dessus
4 axes
un champ énonce sa forme, son rôle, son cycle de vie et sa frontière — chaque projection y lit
1 ligne
l'énoncé de topologie entier : remotes.blog = 'http://…'
5 primitives
toute la surface client : useQuery, useCommand, useFormFor, useCurrentUser, invoke
Ce site est une app Fougere
Les docs que vous allez lire sont du markdown dans git. Le blog derrière /blog est une Frond : les posts sont des entités avec une transition draft→publish jugée, écrits via le contrat de formulaire, lus via la primitive de query — et la Frond entière peut partir dans un autre process en décommentant une ligne de config.