FrontierJS
Skip to content

The flow

One field, all the way down.

This page follows email — a single field on a single Model — from the line that declares it to the input a user types into. Every stop names the concept doing the work, and every Boundary it crosses is marked, because the Boundaries are where the interesting things happen.

Three realms, in order: Data holds the Model, API exposes the Service, UI binds a Resource. Nothing here is a restatement of anything before it.

On the schema. Treat .lite as Prisma with two extra ideas — a Gate and policies. Everything else on this page is unaffected by how deep the Data realm goes.
Realm 01 · DataModeldb/schema.lite

The Model is the seed. It states what exists, what rules govern it, and who may touch it — and it is the only place any of that is written.

enum LeadStatus { new  contacted  won  lost }

model Lead {
  id        Int        
  name      String     (1, 120) 
  email     String       
  status    LeadStatus (new)
  value     Float      (0)
  ownerId   Int
  createdAt DateTime   (now())

  // Gate — the ordinal level check, per operation
  (2.4.4.5)

  // Policy — a row predicate, compiled into the SQL
  (read, ownerId == auth().id)
}
Boundary · Data ⇄ API
db.$setAuth(user)  ·  db.asSystem()

A Service never holds the root client. Every call gets a client scoped to the caller, and that scoping is what makes the Gate and the policies real: the level check runs, and @@allow is compiled into the WHERE clause before the query is executed. asSystem() is the declared bypass for background work — and it does not pass LOCKED.

$.dbGate → 401/403policy → WHEREfield rules → 400
Realm 02 · APIServiceapi/src/services/
leads.service.ts

The Service is what the outside world can ask for. Here is the whole file — it is empty because everything in it would be a restatement of the Model.

// name  ← the filename      ('leads.service.ts' → /api/leads)
// model ← the service name  ('leads' → db.lead)
// db    ← app.db, scoped per call to the caller

export default createService({})

That gives find get create update patch remove at /api/leads, a 400 when email is not an email, a 401 below READER, and a list filtered by the policy — none of it written, all of it read from the Model at call time.

Realm 02 · APIContextctx

One call, one Context. It is the single argument every Hook and every Service method receives, and its parts have different rules — which is the part worth learning early.

On the ContextIsPropagates to an internal call?
ctx.userWho is calling. Frozen.Yes — identity must stay consistent
ctx.callerip, user-agent, headers. Read-only.Yes — {} when there is no real caller
ctx.queryFilters only. Becomes the WHERE.Per call
ctx.directivesShaping only — limit, offset, orderBy, select.Per call
ctx.localsScratch space for this call. Fresh {}.No — a sub-call cannot reach its caller
ctx.result / ctx.errorNull before, populated after.Per call
$ is wire syntax and stops at the Boundary.?$limit=20 arrives as ctx.directives.limit; nothing inside the framework ever reads a $. Conflating the two is what made ?limit=1 a filter on a column named "limit" — zero rows, no error.
Realm 02 · APIHookChain of
Responsibility

A Hook is how you step into a call without owning it. Four phases, declared per method or for all, and they are the same four phases you will meet again in the UI realm.

export default createService({
  hooks: {
    around: { all:    [timing] },        // wraps the whole call
    before: { create: [stampOwner] },    // may mutate ctx.data or halt
    after:  { create: [queueWelcome] },  // sees ctx.result
    error:  { all:    [report] },        // sees ctx.error
  },
})

function stampOwner(ctx) {
  ctx.data.ownerId = ctx.user.id   // who, from the Context
}

Your Hooks run first, then the ones derived from the Model — so stampOwner can complete the record before the Gate check and the field rules judge it. Setting ctx.result in a before Hook ends the chain early; throwing sends it to error.

Realm 02 · APIPluginapi/src/app.ts

A Plugin extends the application and claims a name on it. Everything a Hook can then reach for — the queue, the mailer, the outbound boundary — arrived this way.

const app = createApp({ db, auth })

app.configure(caravan({ jobsDir: './jobs' }))  // → app.jobs
app.configure(conduit({ … }))                        // → app.conduit

// the after Hook from the previous stop
async function queueWelcome(ctx) {
  await ctx.app.jobs.dispatch('send-welcome', { email: ctx.result.data.email })
}
The contractWhen
register(app) Immediately, and synchronously — claim your name with app.provide()
boot(app)At startup, async — connections, migrations, workers
ready(app) / shutdown(app)After listening / on the way down
requires: ['mailer']Checked once before any boot — ordering, declared
Realm 02 · APIEvent · Channelchannel: 'leads'

An Event is what happened. A Channel is who hears about it. A mutation is announced in exactly one place — the call itself — so no Hook and no method can forget to publish.

export default createService({
  channel: 'leads',   // the declared broadcast target
})

// one mutation, announced twice, from the same place:
app.events.on('leads:created', …)   // in-process Event bus
// 'leads created'                   → over the Channel, to the UI
Omitting channel: means no broadcast at all — there is no default. And a Channel named as a plain string reaches every connection on it: policies are enforced on read, not on broadcast. Scope it with the function form before shipping anything per-tenant.
Boundary · API ⇄ UI
bridge.toContext()  ·  toResponse()  ·  the envelope

Going out, this is where a Context becomes an HTTP response and where the result gets its envelope — kind: 'single' | 'list', with a list keeping the envelope and a single unwrapping. Coming in, it is where $limit becomes a directive and a thrown value becomes a status. The UI realm never guesses a shape, because one module owns both ends of it.

envelope$ → directivestoFrameworkError()JSON SchemaChannel events
Realm 03 · UIResourceweb/src/routes/
leads/index.mesa

A Resource is how the UI binds to a Service. It carries the data — and, because the Model's rules crossed the Boundary with it, it carries the rules too.

const leads = createResource('leads')

// "42" → 42 by the Model's declared type, '' → null on a nullable field, and
// the Model's own rules checked here before a round trip is spent — all three
// are on, and each takes an explicit false.

leads.fields.email   // { type, required, maxLength, format: 'email', … }
leads.can('delete')  // the Gate's answer, for hiding a control
leads.gate           // the four levels, as declared on the Model
Realm 03 · UIHook · Eventthe same four phases

The UI realm's Hooks are the API realm's Hooks. Same names, same order, same Context idea — learned once.

const leads = createResource('leads', {
  hooks: {
    around: { all: [async (ctx, next) => {
      busy = true; await next(); busy = false
    }] },
    error: { all: [(ctx) => toast(ctx.error.message)] },
  },
})

// and the Channel from the API realm arrives as an Event here —
// the list updates without asking again
Realm 03 · UIThe field<Input />

The end of the journey. The input knows it is an email, knows it is required, knows its maximum length, and knows when it is wrong — and none of that is written here.

<script>
  import { Input, Select, Button } from '@frontierjs/ui'

  const leads = createResource('leads')
  let draft = leads.make()          // shaped by the Model

  // validate() returns [{ field, message }] — the components want a map
  $: errors = Object.fromEntries(
    leads.validate(draft, 'create').map(e => [e.field, e.message])
  )
</script>

<Input  label="Email" name="email" type="email"
        bind:value={draft.email} {errors} />

<Input  label="Name"  name="name"  bind:value={draft.name}  {errors} />
<Select label="Status" name="status" options={leads.fields.status.enum}
        bind:value={draft.status} {errors} />

<Button onclick={() => leads.service.create(draft)}>Save</Button>

coerce turns the string every control produces into the type the Model declared. validate checks it against the same rules the Service will apply. If both are switched off, nothing breaks — the server still says no. They only change where the answer arrives.

Boundary · back the way it came
create → Service → Gate → policy → row

Pressing Save sends the record back through every stop in reverse, and every check runs again on the server side of the Boundary. The browser's copy of the rules never had authority — it had a preview.

What was written, and what was read

The whole path above, tallied. Everything in the right column exists because of a line in the left one.

Written by you

# the Model
db/schema.lite            16 lines

# the Service
leads.service.ts          3 lines

# the UI
leads/index.mesa          the form you wanted

Read from the Model

The migrationcolumn, type, UNIQUE
The Servicesix operations at /api/leads
The 400from @email, @length
The 401/403from the Gate
The WHEREfrom the policy
The typesserver and browser
leads.fields.emailthe browser's copy of the rule
The inputtype, required, maxlength
Vocabulary per ARCHITECT.md §2 — mandatory, not stylistic. Code read from source 2026-08-04.Styled entirely by @frontierjs/css. Home →