Before & after
None of these are hard. That is the problem — each one is easy enough that you just write it again in the database, again in the API, and again in the browser, and then keep three copies in agreement forever. Nothing fails when they drift. A user finds out.
The left column is a real stack, written the way you would actually write it. The right column is the same job when the schema is the thing everyone reads.
“Only the owner can read a lead.” One sentence in the standup, three implementations in the repo.
Before — and nothing errors when they disagree
Database
CREATE POLICY lead_owner ON leads
FOR SELECT USING (owner_id = current_setting('app.user')::int);API
router.get('/leads', requireAuth, async (req, res) => {
const rows = await db.lead.findMany({
where: { ownerId: req.user.id }, // don't forget this
})
res.json(rows)
})UI
{lead.ownerId === session.user.id && (
<EditButton /> // a third opinion, in a third language
)}After
db/schema.lite
model Lead {
ownerId Int
(read, ownerId == auth().id)
} The policy compiles into the query, so a route added next year is filtered too. The API returns 403 without a guard clause, and leads.can('update', lead) answers the same question in the browser — as an affordance, never a boundary.
An email is an email in all three realms. You still declare it in all three, and the browser's copy is always the one that goes stale.
Before
Database
ALTER TABLE leads
ADD CONSTRAINT leads_email_unique UNIQUE (email),
ADD CONSTRAINT leads_value_positive CHECK (value >= 0);API
const LeadInput = z.object({
name: z.string().min(1).max(200).trim(),
email: z.string().email().toLowerCase(),
value: z.number().min(0),
})UI
<input {...register('email', {
required: true,
pattern: /^\S+@\S+$/, // the fourth spelling of "email"
})} />After
db/schema.lite
model Lead {
name String (1, 200)
email String
value Float (0)
} The migration takes the constraints. The API's 400s are derived from the same rules. leads.fields.email is that rule in the browser — not a copy of it — and leads.make() renders an input the schema already describes.
Read is public, create needs a member, delete needs an admin. Now say that on every route, and again on every button.
Before
API
router.get ('/leads', leads.list)
router.post ('/leads', requireRole('member'), leads.create)
router.patch ('/leads/:id', requireRole('member'), leads.update)
router.delete('/leads/:id', requireRole('admin'), leads.remove)
// repeat, per resource, foreverUI
const canDelete = user?.role === 'admin'
// the role names now live in two reposAfter
db/schema.lite
model Lead {
// read · create · update · delete
(0.4.4.5)
} Every service on that Model answers 401/403 at the right level without a guard, because the gate is under the data, not in front of the route. leads.can('delete') hides the button using the same four digits.
Every input value is a string, including <select> and type="number". And an empty box is '', which your nullable column does not consider empty.
Before
UI
onChange={e => setValue(parseFloat(e.target.value))}
// NaN when the box is empty
const payload = {
...form,
value: Number(form.value),
nickname: form.nickname || null, // remember this one
notes: form.notes || null, // and this one
}Database
-- meanwhile, in the column you thought was empty
SELECT * FROM leads WHERE nickname IS NULL;
-- 0 rows. They are all ''.
-- and String? @unique accepted exactly one ''After
web/src/routes/leads
const leads = createResource('leads')
// "42" → 42 by the schema's type, '' → null on a nullable column, and the
// schema's rules checked before it leaves the browser. Nothing is turned on:
// naming the model is the whole of it. Runs coerce → blankToNull → validate, so validation judges what is actually sent. '' is never coerced to 0, and a string that is not cleanly the target type is left alone for the server to reject.
Every endpoint invents its own — a bare array here, { data, total } there — and every caller unwraps it differently.
Before
API
router.get('/leads', async (req, res) => {
const page = Number(req.query.page ?? 1)
const limit = Math.min(Number(req.query.limit ?? 20), 100)
const [rows, total] = await Promise.all([…])
res.json({ data: rows, total, page }) // this endpoint's shape
})UI
const res = await fetch('/api/leads?page=2')
const body = await res.json()
const rows = body.data ?? body.items ?? body // hopeAfter
Any service, no code
GET /leads?$limit=20&$offset=40&$orderBy=createdAt
// $ is transport syntax and stops at the bridge:
ctx.query // your filters
ctx.directives // { limit, offset, orderBy, select } One envelope with kind: 'single' | 'list' as the discriminant — a list keeps it, a single unwraps. One module owns wrapping and unwrapping, so the browser never guesses.
Four libraries throw four shapes. Your error middleware grows an instanceof ladder, and anything it does not recognize is a 500 — including the 404 you meant.
status on your errorBefore
API
app.use((err, req, res, next) => {
if (err instanceof ZodError) return res.status(400)…
if (err instanceof PrismaKnownError) return res.status(409)…
if (err.name === 'JsonWebTokenError') return res.status(401)…
if (err instanceof HttpException) return res.status(err.status)…
res.status(500).json({ error: 'Internal' }) // everything else
})After
Your package, anywhere
export class QuotaExceeded extends Error {
status = 429 // that is the whole integration
}
throw new QuotaExceeded() // raw route, service or plugin One boundary turns a thrown value into a status, and it recognizes a numeric status without registration. Your errors need no dependency on the framework — and for a third-party error you cannot modify, register a mapper instead.
A product manager adds “deal value”. Count the files.
fli db:migrateBefore — in order, and you must remember the order
Six files
1. migrations/0034_add_value.sql // hand-written
2. schema.prisma // the model
3. lib/validators/lead.ts // the zod schema
4. openapi.yaml // the documented shape
5. types/lead.d.ts // what the client believes
6. components/LeadForm.tsx // the input and its rules
// forget 3 → it saves unvalidated
// forget 4 → the docs quietly lie
// forget 5 → the client is wrong at runtime onlyAfter
db/schema.lite
model Lead {
+ value Float (0)
}Then
$ fli db:migrate The migration, the 400, the JSON Schema at /manifest, the TypeScript type, leads.fields.value and the input make() renders all move together — because none of them was ever a copy. There is no generated/ directory to review or re-apply edits to.
Every handler that writes must also remember to announce. The one that forgets is the bug you cannot reproduce.
Before
API
const lead = await db.lead.create({ data })
io.to(`org:${req.user.orgId}`).emit('lead:created', lead)
// …and in update. and in bulk import.
// and in the admin tool. and in the job.UI
socket.on('lead:created', l => setLeads(p => [...p, l]))
socket.on('lead:updated', l => setLeads(p => p.map(…)))
// event names agreed by convention across two reposAfter
api/src/services/leads.service.ts
export default createService({
channel: 'leads', // the declared broadcast target
})
// name and model come from the filenameA mutation is announced in one place — the same call that fans out to the event bus and to the channel. A handler cannot forget to publish, because publishing is not something a handler does.
A queue usually arrives with a Redis to run, a worker process to deploy, and a second place your app config lives.
Before
Infra
# docker-compose.yml
redis:
image: redis:7
worker:
command: node dist/worker.js # a second deploy targetAPI
const queue = new Queue('email', { connection })
const worker = new Worker('email', handler, { connection })
// plus a separate node-cron in yet another processAfter
jobs/send-email.job.ts
export default defineJob({
handler: async (job) => { await mailer.send(job.data) },
maxAttempts: 5,
retryDelay: [60_000, 300_000],
})Anywhere in the app
await app.jobs.dispatch('send-email', { to: lead.email })
app.jobs.schedule('digest', '0 9 * * *', sendDigest) Same process, same file, same deploy. Retries, attempts and cron state are rows you can query — and job stats show up on /metrics because the queue is a plugin, not a neighbor.
“Tell them in the app, and email them, and push it live.” Three code paths, and the copy is written three times so they drift immediately.
Before
API
await db.notification.create({ data: { userId, title: 'Payment received', … } })
await sendgrid.send({ to: user.email, subject: 'Payment received', html: render(…) })
io.to(`user:${userId}`).emit('notification', { title: 'Payment received' })
// three wordings, one of which is now out of date
// and no per-user preference unless you add a fourth thingAfter
api/src/notifications/PaymentReceived.ts
class PaymentReceived extends Notification {
via(user) { return user.notificationPreferences ?? ['inApp', 'email'] }
toInApp(user) { return inApp().title('Payment received')… }
toEmail(user) { return mail().subject('Payment received')… }
}Anywhere
await app.notify(user, new PaymentReceived(payment)) One class holds the message, and via() decides the channels per user. The in-app record, the live event and the email fan out from the same object.
Your app is components. Your email is a different templating language, a different component set, and a plain-text version nobody maintains.
emailBefore
<mjml><mj-body><mj-section><mj-column>
<mj-text>Hi {{firstName}}</mj-text>
</mj-column></mj-section></mj-body></mjml>
// a second syntax, a second component library,
// and a text/plain part that is either absent
// or full of leftover markupAfter
emails/Welcome.mesa
<Button href={url}>View order</Button>
// the same component language as your pagesSending
const { html, text, subject } =
await renderEmailFile('./emails/Welcome.mesa', { data })Table-based markup and inlined CSS come out the other side, with the Outlook fallback handled and a real plain-text alternative derived from the same render — not a stripped copy of the HTML.
Outbound fetch spreads until no one can answer “what does this service talk to, and with which credentials?”
Before
API, scattered
await fetch(`https://api.hetzner.cloud/v1/servers/${id}`, {
headers: { Authorization: `Bearer ${process.env.HETZNER_TOKEN}` },
})
// a token read at the call site, a timeout nobody set,
// a retry policy that exists in three variantsAfter
Declared once
target('provider:hetzner', {
base: 'https://api.hetzner.cloud/v1',
auth: { ref: 'HETZNER_TOKEN' }, // a reference, not the secret
})Anywhere
const result = await app.conduit.send({
target: 'provider:hetzner',
method: 'GET',
path: '/servers/123',
}) Everything the app can reach is enumerable, credentials are references resolved at send time, and the result is { data } | { error } rather than a thrown fetch.
Every line in the left column is code a good developer writes correctly. The cost was never difficulty — it was that the same fact lived in three places, and keeping them in agreement is work that never finishes and never appears on a roadmap.
@frontierjs/css. Home →