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 wantedThe flow
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.
.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. 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)
} 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.
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.
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 Context | Is | Propagates to an internal call? |
|---|---|---|
ctx.user | Who is calling. Frozen. | Yes — identity must stay consistent |
ctx.caller | ip, user-agent, headers. Read-only. | Yes — {} when there is no real caller |
ctx.query | Filters only. Becomes the WHERE. | Per call |
ctx.directives | Shaping only — limit, offset, orderBy, select. | Per call |
ctx.locals | Scratch space for this call. Fresh {}. | No — a sub-call cannot reach its caller |
ctx.result / ctx.error | Null 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. 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.
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 contract | When |
|---|---|
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 |
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 UIchannel: 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. 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.
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 ModelThe 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 againThe 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.
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.
The whole path above, tallied. Everything in the right column exists because of a line in the left one.
# the Model
db/schema.lite 16 lines
# the Service
leads.service.ts 3 lines
# the UI
leads/index.mesa the form you wanted| The migration | column, type, UNIQUE |
| The Service | six operations at /api/leads |
The 400 | from @email, @length |
The 401/403 | from the Gate |
The WHERE | from the policy |
| The types | server and browser |
leads.fields.email | the browser's copy of the rule |
| The input | type, required, maxlength |
ARCHITECT.md §2 — mandatory, not stylistic. Code read from source 2026-08-04.Styled entirely by @frontierjs/css. Home →