Write the domain. Everything derives from it — down to the process it runs in.
One class declares the business object: validation, table, form and surfaces derive from it. The day it moves to its own process — or another language — the code calling it does not change.
The code below is this site's own blog Frond — not pseudocode.
The model: one declaration, everything projects
An entity is not a table. The table, the validation, the form, the API are projections of one declaration — change it, every projection follows.
The schema — declared once
class Post extends entity({
id: primary(),
title: text({ min: 1 }),
status: readOnly(oneOf(
'draft', 'published')),
}) {}Post.validate(input)derived from the shape · ships with the class
API surface
post.list · post.publish
Database table
auto-DDL → SQLite
TypeScript type
function render(p: Post)
Form contract
useFormFor(Post)
GraphQL type
type Post { … }
Designation & DI
useQuery(Post, 'list')
One nucleus, six projections — change the declaration, every projection follows.
1 Declare
One entity class. Validation, SQLite table, GraphQL type, form contract — all projections of it.
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 Judge
Operations, not field writes. readOnly closes the inbound door; the server stamps the pair.
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 Consume
The imported class designates the call. A command on Post revalidates every query on 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 revalidatesThe gradient
A Frond runs in-process or in its own process behind JSON-RPC 2.0 — with identical user code. No RPC without travel: local calls are direct memory execution.
remotes: { blog: 'http://127.0.0.1:4100' }— the only line that changes- Errors travel intact: same code, message and per-field details either side
- Session state reaches remote collectors — trust is intra-topology
- Dead host → typed 503 in your pages; restart → recovery, app untouched
A Frond does not have to be TypeScript
A Frond honours two contracts and both are JSON: the wire (JSON-RPC 2.0) and the map (rpc.discover, which returns what it hosts, schemas included). Neither mentions TypeScript. demos/rust-frond is a telemetry domain written in Rust — there is no entity class anywhere in it, the declaration lives in src/main.rs.
The consumer asks for the map, rebuilds a live schema from it, and refuses a bad payload before any network happens. Those refusals are the four axes crossing a language boundary: shape is the JSON Schema, role, lifecycle and boundary ride under x-fougere. The rules travel, not just the types.
$ npx tsx consumer.ts
✗ couleur — Unknown field
✗ celsius — 250 is greater than 80.
✗ checksum — Read-only
✗ label — String is too short (1 < 2).Rules declared in Rust, enforced by the TypeScript judge — no line of TS declared them
What the model makes disappear
A consequence you can see: without a model, every app re-declares the same shape in the validator, the table, the endpoint and the form — four files that must never drift. With one, the declaration is alone and everything else derives.
// 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 declarations of the same shape, kept in sync by hand
// 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 declaration — everything else is derived
Don't take our word — make your agent count
The duplicated glue is the measurable trace of a missing model. Paste this prompt into the AI agent that already knows your codebase (Claude Code, Cursor…): it counts the re-declared shapes and the sync wiring in your app — and reports the adoption costs just as carefully.
# 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.Where Fougere stands
Pre-release — but not a promise. Each of these has been seen running:
- The 5 client primitives are the only path — the old world was deleted, not half-replaced
- A judged business feature (draft→publish) verified in the browser, badge flip included
- The split is lived daily: kill the host → typed 503 in the pages; restart → recovery
- Identical user code in-process and split — verified down to the production build
- This site — docs, blog, auth — runs on it
4 axes
one field states its shape, role, lifecycle and boundary — every projection reads from them
1 line
the entire topology statement: remotes.blog = 'http://…'
5 primitives
the whole client surface: useQuery, useCommand, useFormFor, useCurrentUser, invoke
This site is a Fougere app
The docs you are about to read are markdown in git. The blog behind /blog is a Frond: posts are entities with a judged draft→publish transition, written through the form contract, read through the query primitive — and the whole Frond can move to another process by uncommenting one line of config.