What your app is, and what it does. Everything else derives.
Validation, database tables, API surface, form contracts, GraphQL types — from a single class.
// 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 validator)
// SQLite table + additive schema sync
// form contract useFormFor(Post)
// API surface post.create / post.list
// GraphQL type type Post { … }One idea, and it is the kind you can check
The declaration names nothing outside itself — no table, no protocol, no host, no address. So what it does not name is either derived from it, or chosen outside it. The two sections below are those two readings, and nothing else.
First reading — what is derived from it
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(postFacade, '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 Validate
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.storage.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 '@fronds/facade';
const { items } = await useQuery(post, 'list');
const publish = useCommand(post, 'publish');
await publish.execute({ params: { id } });
// → every mounted query on that facade revalidatesSecond reading — what is chosen outside it
The 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.
- 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 validator — 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 validator)
// 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
What your next business object costs is already in your git history. Paste this prompt into the AI agent that already knows your codebase (Claude Code, Cursor…): it counts the files one new object and one new field really touch, finds where two declarations already disagree — and reports the adoption costs just as carefully.
# Audit: what does the NEXT business object cost here?
You are auditing THIS repository. Be honest: report the costs of
adopting as carefully as the gains, and what would get WORSE as
carefully as what improves. Nothing below asks you to price a
rewrite — Fougere is added beside what exists, one object at a
time, and the question is what the next one costs.
## 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 validator
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), validated server-side. Moving a module to its
own process is one line of config; user code does not change, and
one optional package puts a span on every operation.
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
(createKyselySource) — only you have the driver. No search-index
projection, though an external source is mirrored into entities
you declare; auth via better-auth (credentials + OAuth), or the
sessions you already have behind an auth provider whose
getSession reads them. Price the adoption costs against THIS
scope, not an imagined one.
If you can fetch the web, ground yourself in the docs first:
- https://fougere.dev/docs — the model in one page
- https://fougere.dev/docs/schema/entities — field vocabulary, the 4 axes
- https://fougere.dev/docs/client/forms — the shared browser/facade validator
- https://fougere.dev/docs/infra/gradient — the process boundary as config
- https://fougere.dev/docs/infra/observability — the span nobody writes
- https://fougere.dev/docs/existing-app — the feature-by-feature path
(use it to price the adoption cost honestly)
- https://fougere.dev/docs/demos — whole projects, if you want the shape
of a finished one
## Measure, in this repo
Every section asks the same question about a different subject:
for one thing Fougere DERIVES, how many lines does this repo
write by hand? A declaration should name nothing outside itself —
not a table, not a protocol, not a host, not an address — and
nothing should have to be written twice per operation.
Measure. Quote real paths and numbers you counted. Where a
section finds nothing, say "nothing" — that is a result.
### 1. What the next object costs
Do NOT start from the most-touched objects. Churn selects the
most MATURE ones — most business logic, highest migration cost,
least reason to move. That is the unfavourable case and nobody
buys it. Start from what was added recently.
A business object is something a person creates or edits: it has
a form, or a public API route. A join table, a job queue, a
session or an idempotency key is not one. Say which you excluded.
a. Find the business objects added in the last ~12 months.
`git log --diff-filter=A` over migration files works only if
this repo HAS per-change migration files. If it does not (a
`db push` workflow, one schema file per domain, a single
models.py), search the diffs instead:
`git log -p --since=1.year -- <schema paths> | grep '^+model\|^+class\|^+CREATE TABLE'`
For each, count the files its introduction touched.
b. Do the same for commits that add ONE field.
c. Report the MEDIAN of each, over at least five commits, not a
single example — one commit is noise. Say how many you used.
d. Say how OFTEN this happens: new objects per year, new fields
per year. A high per-object cost paid twice a year is a
different argument from one paid every week, and the honest
report says which this is.
### 2. What a rename costs
Find a commit that renamed a business object or one of its
fields, and count the files it touched. If there is none, take
the object from 1a and count the files that would have to change
to rename one field.
This is the number a single declaration changes most, and it is
usually the largest one in the repo.
### 3. Where the shape is re-declared
Take the object from 1a and the one or two the app is most built
around. List every file where their 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.
Two traps worth naming:
- an object's shape may be FED by an external system (legacy
API, search index). You still declare the shape and everything
derives; a Mirror writes pages into it. Only the translation of
the foreign payload stays. Count the two separately.
- a constraint can disagree with a default nobody typed — a
`.max(255)` against a column that is `VARCHAR(191)` because
the ORM defaults there. Check the defaults, not just what is
written.
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. Where the code names a place
Count the files of BUSINESS code — not config, not the HTTP
layer itself — that name where they run:
- base URLs or per-service env vars read outside one config file
- hand-written fetch/axios wrappers, one per service called
- imports of request/response types, or of any host-specific
context object, inside domain code
- anything that would have to change to run on another runtime
(Node, edge, a worker)
Report that file count. It is the answer whether or not this
repo is one process or intends to stay one — a single process
that names its host in domain code has the same coupling, it
just has not paid for it yet.
Then, if the repo is or wants to be more than one process: pick
the module you would most plausibly split, say where you drew
its boundary, and count the files that would change. Fougere's
answer is one config line with user code untouched.
### 5. What you write around every operation
Fougere derives a span per operation, the four signals, and test
cases from the entity. Count what this repo writes by hand for
the same result:
- lines that start or annotate a span, time a block, or log
entry/exit around business calls
- how a request ID or trace context is carried between two
services, and how many files carry it
- test fixtures that restate an object's shape — a factory, a
builder, a JSON sample per test
If this repo has no tracing and does not want any, say so.
### 6. The drift
Find where two declarations of the same thing already disagree —
a max length, an optional, a nullable, an enum with an extra
value. Sweep rather than hunt: a short script comparing declared
lengths on both sides usually finds a family at once. Report
every family you find, quote the two declarations, and say what
breaks in the gap between them. If there is none, say so plainly
rather than reaching.
## Report
Lead with the rule that selected the objects you audited and
the ones it rejected. Then four numbers: files per new object,
files per new field, files per rename, files of business code
that name a host. Then the drift from 6, quoted, with what breaks.
Then, honestly:
- what those numbers become under a derive-everything model, and
how many of the lines from 3 and 5 stop existing;
- what this repo ALREADY derives — generated clients, inferred
types, a shared validator. Those lines are not a gain, and a
report that counts them is lying;
- what would get WORSE. Name the database features in use that
Fougere's scope does not cover today, and anything it would
make harder. An audit that finds no downside has not looked;
- what adopting would cost HERE, in the units the gains use. A
cost is a count of files, a named mechanism, or a version —
never a grade. An object added beside what exists needs no
table handover, so name instead what a second writer in this
database means for the migration tool already installed, the
pre-release status (npm alpha tag, APIs still settling), the
feature-by-feature path, and what the team must learn. Price a
handover only for an object you propose to MOVE, and say why
it has to move;
- where the FIRST entity would go. Name the next object this team
is about to write — a branch in progress, a table nobody reads
yet — not the biggest one they already have. If nothing is
coming, say that: it is the real answer.Where Fougere stands
Pre-release — but not a promise. Each of these has been seen running:
- The 5 primitives are the only path — the old world was deleted, not half-replaced
- A validated 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 call surface: useQuery, useCommand, useFormFor and useCurrentUser in the page, and invoke, their server dual
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 validated 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.