Le seul framework que vous pouvez choisir n'importe quand.
Jour 1 ou jour 400 : vos routes, vos pages, votre rendu et vos données ne bougent pas. Ce que vous réécrivez, c'est le modèle — une entité à la fois.
Le code ci-dessous est la Frond blog de ce site — pas du pseudocode.
Une seule idée, et du genre qui se vérifie
La déclaration ne nomme rien en dehors d'elle-même — ni table, ni protocole, ni hôte, ni adresse. Ce qu'elle ne nomme pas est donc soit dérivé d'elle, soit choisi en dehors. Les deux sections qui suivent sont ces deux lectures, et rien d'autre.
Première lecture — ce qui en dérive
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.storage.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 '@fronds/blog/entities/Post';
const { items } = await useQuery(Post, 'list');
const publish = useCommand(Post, 'publish');
await publish.execute({ params: { id } });
// → every mounted query on Post revalidatesSeconde lecture — ce qui est choisi en dehors
Le 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.
- 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 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 d'appel : useQuery, useCommand, useFormFor et useCurrentUser dans la page, et invoke, leur dual serveur
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.