chok/fougere
Write the domain: one TypeScript entity class becomes your validation, SQL tables, GraphQL/REST API and forms — no codegen. Moving it to its own process is one line of config. - chok/fougere
3 stars · 0 forks
README
🌿 Fougere Write the domain. Everything else is a projection of it — including the wire. One class declares the business object. From it come the TypeScript type, the validator, the SQL table, the form contract and the API surface — and because the declaration is JSON on the wire, the domain can move to its own process, or to another language, while the code calling it does not change.
Documentation →
// fronds/blog/entities/Post.ts — this project's own blog, trimmed of three fields export default class Post extends entity({ id: primary(), title: text({ min: 1, max: 160 }), body: optional(text()), authorId: readOnly(ref(User)), // a User of another frond createdAt: created(), status: readOnly(oneOf('draft', 'published', { default: 'draft' })), publishedAt: readOnly(optional(date())), }) {} readOnly is not a note about intent: it removes the field from what a client may ever send, so publishing cannot be a field write — it has to be an operation. ref reaches into another frond. Both share one database today, so a foreign key holds it and costs nothing; give the blog its own database and no key can — two of them share no constraint — so the write reads the row instead, and refuses the same insert. What neither can reach is the third answer, and the boot names it rather than letting you find out. One field changes, and there is one place to read. The table, the validator, the GraphQL type and the form contract are derived from it, so they cannot disagree with it — and a declaration the framework can prove wrong is refused at boot, naming it. A diff stays reviewable whoever wrote it: you, a colleague, or an agent. Quick start With pnpm, a version published too recently is held back by its minimum-release-age. To try the latest one right away: pnpm --config.minimum-release-age=0 create fougere You now have a running app: the table created, the form contract, the REST and GraphQL surfaces, and pages calling operations through useQuery / useCommand. Nothing above was generated into a file you have to keep. Or adopt nothing An entity is a Standard Schema, so it is accepted wherever one is — tRPC, Hono, TanStack Form, and the server frameworks adopting the spec for route-level validation. One npm i @fougere/schema, no adapter package, nothing else of Fougere in your app. export class PostDraft extends Post.pick('title', 'summary', 'body') {}
PostDraft['~standard'].validate({ title: '' }); // { issues: [{ message, path: [{ key: 'title' }] }] } Be clear about what crosses: the validator, and only the validator. The other three axes stay home — no table, no GraphQL type, no form contract. The entity is the piece that fits through the hole; the reason to come back for the rest is getFields(). What derives from it
Validation the same validator in the browser and at the façade — unknown keys refused
Storage the SQL table and additive schema sync
Forms useFormFor(Post) — fields, rules, per-field error mapping
API surface post.list, post.create, post.publish…
GraphQL · REST the types, the inputs, the routes — from the same operations
Types the class is the type
No codegen step, no dist/generated, no watcher. The declaration is the artefact. What stays yours The interesting part is never update(). It is the transition, and a transition has a validator — the only code on this page Fougere does not derive. // fronds/blog/handlers/PostHandler.ts export class PostCard extends Post.pick('id', 'slug', 'title', 'summary', 'authorName', 'publishedAt') {}
export default class PostHandler extends Crud(Post, { list: PostCard }) { constructor(private posts: PostRepository) { super(posts); }
/* Validate: the author, a draft, a body worth publishing. Realize: stamp the pair. / async publish(id: string, user?: User): Promise<Post> { const author = requireUser(user, 'publish'); const post = await requireOwn(this.posts, id, author, 'publish'); if (post.status === 'published') { throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published' }); } return this.posts.update(id, { status: 'published', publishedAt: new Date() }); } } user?: User is the injection: the signature is matched by type against the collector that resolves the session. No decorator, no container lookup to write. The domain travels
A Frond is a domain — its entities, handlers, collectors, seeds. Where it runs is one line, and it is the only line that changes: // fougere.config.ts remotes: { blog: 'http://blog-node:4100' }, // delete this line → same app, in-process In-process, a call is direct memory execution, not a loopback request. Split, a host going down is a typed 503 in your pages, and they recover when it returns. The far side does not have to be TypeScript: demos/rust-frond is a domain written in Rust whose rules — not just its types — are enforced by the TypeScript validator. Alpha today Published on npm under the alpha tag. The version is the whole promise: the surface can still move. Seen running, not planned — a validated draft→publish exercised in a browser, the split lived daily, identical user code either side through a production build, and this site is itself a Fougere app. Known limits, because you would find them anyway: the boot never writes a schema — fougere migrate applies what fougere freeze recorded — tables, columns, renames, drops — and a type change needs a migration you write; a computed field costs one read per row unless you name a view. A split receiver binds to loopback by default; widening it requires signed envelopes, or an explicit allowUnsigned when an upstream mesh already authenticated the caller. The full list is in CLAUDE.md, kept honest rather than short. Learn more
Getting started Philosophy Entities and the four axes The gradient Adopting it in an existing app
demos/ isolates one idea per project; nuxt-blog is the flagship.
MIT · built by chok