Pre-release — APIs are settling

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.

fronds/blog/entities/Post.ts
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.

fronds/blog/handlers/PostHandler.ts
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.

app/pages/blog/index.vue
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 revalidates

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.

IN-PROCESSNuxt appuseQuery(Post, 'list')Frond blogdirect memory call — no serializationSPLITNuxt appuseQuery(Post, …):4100Frond blogJSON-RPCthe same call value, framed on the wire
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.

demos/rust-frond — the TS consumer's output
$ 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.

your-nuxt-app/ — 4 files
// 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

your-fougere-app/ — 1 file
// 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-prompt.md
# 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.

Built with Fougere — this site runs on the framework it documents.