The Frond

A frond is one domain of your app — the blog, the catalog, billing — with everything that belongs to it, in one directory under fronds/.

The ownership boundary

A frond holds:

  • its entities: the data it owns,
  • its operations: what it does, as commands and queries (handlers),
  • its rules: who may do what, checked inside the operations,
  • its events: the facts its commands announce, which nobody hears unless they ask to,
  • its storage: where its rows are kept,
  • its public surface: what other fronds may call,
  • what each command refreshes: the queries it makes stale.

A frond never imports another frond's files. It calls the other frond's operations, or listens to its events. Your app puts fronds together, and the business logic stays in them, not in your pages, fetches or stores.

The six layers

From what you write to what runs on several servers, six steps:

#LayerWhat happens
1Declarationyou write entities, views, operations and events — no HTTP, no SQL
2Derivationvalidation, types, input and output views and the table follow from it
3Running locallyyour code runs in one process — the reference mode
4Projectionthe same operations are offered over HTTP, GraphQL, a CLI or a worker
5Compositionyour fronds are found and connected inside one app
6Distributiona frond moves to another process; timeouts and retries appear here, and only here

The gradient page covers the move from local to remote execution.

Generated vs handwritten

The split of responsibilities is:

Fougere generatesYou write
validation, derived views, tables/metadatanon-trivial business logic
GraphQL types and inputs, trivial CRUDrich invariants, multi-step workflows
projection façades (contract → transport)error policies, consistency decisions
typed clients, composition manifestssensitive inter-Frond orchestration

Fougere handles repetitive integrations around a contract. The developer writes application-specific behavior. A service layer remains optional: a handler can contain the logic or delegate it.

In the file system

fronds/blog/
  vocabulary/           ← words this Frond adds to the declaration — read FIRST
  entities/             ← the declarations
  handlers/             ← the operations   (handlers/<surface>/ = a named audience)
  presenters/           ← computed fields added to an entity's output
  collectors/           ← parameter resolution by type
  middlewares/          ← what runs around this Frond's operations
  rules/                ← what the domain computes with nothing injected
  services/             ← plain classes, injected by type
  repositories/         ← the same, under a second name
  seeds/                ← data created at startup
  extensions/           ← what rises and falls with the process
  versions/             ← what its shapes used to be (`fougere freeze`)

Two of them extend the framework itself rather than declaring a domain.

vocabulary/ holds the words a Frond adds to the declaration — a generator, a format, a verb. A file there exports each as a term, and the process lists them beside its fronds: a registry answers every entity, whichever Frond the file sits in. An entity still names the word, and the boot refuses a name nothing answers.

// fronds/blog/vocabulary/ulid.ts
import { Generators } from '@fougere/schema';

export const ulid = Generators.term('ulid', () => …);

// fronds.ts — written by the scan, or by hand
export const vocabulary = [ulid];

// fronds/blog/entities/Post.ts
id: primary({ generate: 'ulid' }),

extensions/ holds what rises and falls with the process, written beside the code it instruments. A file there is recognized by its FORM — a module stating up or down — and it travels with its Frond: moved behind fronds:, it mounts on the process that serves it there. That is why no config key widens it; what must be everywhere is a Frond that is everywhere, which Extension.fronds already answers.

// fronds/blog/extensions/audit.ts
export default {
  name: 'audit',
  up(app) { … },
};

middlewares/ holds what runs around every operation of the Frond, and of the Fronds under it. A middleware extends Middleware and gives back what next() gave it:

// fronds/blog/middlewares/Wrote.ts
import { Logger, Middleware, type AppNext, type OperationContext } from '@fougere/core';

export default class Wrote extends Middleware {
  constructor(private log: Logger) {
    super();
  }

  async around<T>(context: OperationContext, next: AppNext<T>): Promise<T> {
    const answer = await next();
    this.log.info(`${context.address}.${context.operation}`);

    return answer;
  }
}

A misspelled around does not compile. A class in middlewares/ that does not extend Middleware runs around nothing, and the boot warns about it.

A middleware stands around a CALL, whichever door brought it. One that must read the HTTP request itself — a header, a redirect — is a REST middleware, declared in the same directory.

The scanner recognizes a Frond through these directories, in a directory under fronds/. A second domain is added beside the first, and the first does not move.

These names are the only place in Fougere where a name IS the declaration, so they are the only ones a project can restate. conventions: in fougere.config.ts names what differs and nothing else:

export default defineFougere({
  conventions: {
    scope: '@domains',                        // default: '@fronds'
    fronds: 'domains',                        // default: 'fronds'
    dirs: { entities: 'models' },             // the other eleven keep their names
  },
})

It is read before the fronds are discovered, which is what lets it name the scope they are found under — so nothing in fougere.config.ts may import @fronds/*. Everything downstream follows: the scan, the aliases a page resolves, the watcher, and the directory fougere sync writes into a consumer.

Naming and importing a Frond

The directory name is the Frond's name: fronds/blog/ is @fronds/blog, so a page or another Frond writes import Post from '@fronds/blog/entities/Post.js' with no file to add. The name also keys the entity registrations and any fronds: entry.

@fronds/<name> is read where TypeScript reads it — the paths that fronds/tsconfig.json inherits from @fougere/core/tsconfig: fronds/<name>, then .fougere/remotes/<name>, where fougere sync writes a Frond another process serves. Each host reads the same rule, so a Frond created while the server runs resolves at its first import, and no install links it anywhere.

The one thing the directory cannot say is a name that differs from it. That is a paths entry of fronds/tsconfig.json, beside the rule it would otherwise inherit:

{
  "extends": "@fougere/core/tsconfig",
  "compilerOptions": {
    "paths": {
      "@fronds/blog/*": ["./blog-v2/*"],
      "@fronds/*": ["./*", "../.fougere/remotes/*"]
    }
  }
}

fronds/blog-v2/ then stays registered as blog, keeping its entity keys, its imports and its fronds: entries. The rule is written again because paths replaces what it inherits. An app whose tsconfig.json states its own paths (Vite, Next) takes the same entry; Nuxt is told by @fougere/nuxt.

services/ and repositories/ are both scanned as providers, resolved by type. Every entity already has a default repository: Crud(Entity) receives it automatically, and other code asks for Repository<Entity> or a declared <Entity>Repository. Direct Storage injection into a handler, presenter or collector is refused at boot. Write a repository when a query deserves a name or several entities form an aggregate; write a service for a source that is not storage at all (an external API or a search index).

When one of those classes extends another, the base becomes a port: the handler declares the base and receives the implementation, and which one answers is a line in fougere.config.ts — never in the Frond.

Without the scan

The directories above are what the scan reads. What it writes from them is a fronds.ts — the list of frond() calls an author would write — and that file is what every host boots from. Writing it yourself is how an app runs with no @fougere/compiler installed.

A class says most of what it is: Crud(Post) its five operations, Presenter(Post) its fields. What TypeScript erases — the parameters of a method you wrote, and what it answers — is stated, and nothing is guessed:

// fronds.ts
import { frond } from '@fougere/core';
import Post from './fronds/blog/entities/Post.ts';
import User from './fronds/user/entities/User.ts';
import PostHandler from './fronds/blog/handlers/PostHandler.ts';
import CurrentUserCollector from './fronds/blog/collectors/CurrentUserCollector.ts';
import { slug } from './fronds/blog/vocabulary/slug.ts';

export const vocabulary = [slug];

export default [
  frond('blog', {
    entities: [Post],
    collectors: [CurrentUserCollector],
    handlers: [{
      ctor: PostHandler,
      operations: {
        publish: { params: { id: [Post, 'id'], user: { type: User, optional: true } }, output: Post },
        mine: { params: { user: { type: User, optional: true } }, output: [Post] },
      },
    }],
  }),
  frond('user', { entities: [User] }),
];

[Post, 'id'] is a field of an entity, user is what the collector answers, [Post] answers several, and whether an operation is a query or a command is told by its name: only read verbs make a query. What the file does not list is not served — the boot cannot tell a method left out from one kept private, since TypeScript erases the keyword. fougere check reads the sources and names a public method the file does not list.

Built with Fougere and Nuxt, hosted on GitHub Pages.