← the tour · concept
concept iii
A feature, end to end
One folder, four files. The dependency graph stays acyclic because edges only cross through a slice's published contract.
Every feature in orion lives in a single folder. That folder contains exactly four files: a sql module that owns the schema and the queries, a view module that turns store state into HTML, a commands module that validates, mutates, and publishes, and a routes module that wires them into the HTTP resource. No models directory, no controllers directory, no services layer smeared across the tree. The feature is the unit — and the machine enforces it.
schema · queries → view
store → Html → commands
validate · write → routes
the resource
The sql file: schema ownership
Every table belongs to exactly one feature's migration list, forever. That's not just
convention — orion's store.migrate diffs sqlite_master around
each entry, attributes every schema object to its root table, and registers ownership in an
internal tracking table. A migration that touches another feature's table throws at boot.
The feature's *.sql.ts is the data API: the only way another slice reads this
data is through the exported query functions, never through raw SQL against the table
directly.
// features/todos/todos.sql.ts — schema ownership + named queries
import type { Store } from "../../../belt/store.ts"
// Append-only, forward-only. Entry 0 already ran in production —
// schema changes are NEW entries, never edits to existing ones.
export const migrations = [
`create table todos (
id integer primary key,
title text not null,
done integer not null default 0,
created_at text not null default (datetime('now'))
)`,
// Forward-only in practice: when RBAC landed, this was appended.
`alter table todos add column created_by integer`,
]
export type Todo = { id: number; title: string; done: number }
export const all = (store: Store) =>
store.query<Todo>("select id, title, done from todos order by id")
export const insert = (store: Store, title: string, createdBy: number | null) =>
store.exec("insert into todos (title, created_by) values (?, ?)", title, createdBy)
export const remove = (store: Store, id: number) =>
store.exec("delete from todos where id = ?", id)
No ORM, no query builder. The framework's only opinion here is where
this lives (inside the feature) and what shape it takes (plain functions over Store).
The five-method Store seam is synchronous by design — views call sql.all(store)
during render with no await.
The view file: a pure function, run forever
The view module is the read side. Its exports are pure functions from state to HTML — no client state to drift, no optimistic updates to reconcile, no per-row patch bookkeeping. The live stream re-renders the whole region on every relevant event and the browser morphs the difference. The view just describes what the current store looks like.
// features/todos/todos.view.ts — the read side: state → Html
import { html, type Html } from "../../belt/html.ts"
export function TodoList(
todos: Todo[],
viewers: string[] = [],
canDelete: (todo: Todo) => boolean = () => false,
): Html {
const remaining = todos.filter((t) => !t.done).length
return html`
<section id="todos">
${presenceAvatars(viewers)}
<p role="status">${remaining} of ${todos.length} remaining</p>
<ul>${todos.map((todo) => TodoItem(todo, canDelete(todo)))}</ul>
</section>`
}
/** Per-viewer affordances: the delete button only exists in the DOM
* of viewers holding the grant. The command re-checks regardless —
* the UI is a courtesy, authorization lives on the write side. */
export function TodoItem(todo: Todo, canDelete: boolean): Html {
return html`
<li id="todo-${todo.id}" class="${todo.done ? "done" : ""}">
<input type="checkbox" ${todo.done ? "checked" : ""}
data-on:change="$done = el.checked; @patch('/todos/${todo.id}')" />
${todo.title}
${canDelete && html`<button data-on:click="@get('/todos/${todo.id}/confirm-delete')">×</button>`}
</li>`
}
Signals are used for interaction only — the add-form draft, the inline-edit draft, which editor is open. Shared state lives in the store. The view doesn't hold any of it.
A view is a function of the store, nothing else. There is no client state to drift, no optimistic update to reconcile — the server re-renders the complete region and the browser morphs the difference. Data lives in the feature, as named SQL.
The commands file: validate, mutate, publish
The commands module is the write side. Each command follows the same shape: parse and validate the incoming signals, mutate the store, publish a topic that triggers re-renders, and send only requester-scoped feedback back in the response — a form reset, a validation error, a toast. Shared state never comes back in a command response. It flows to everyone through their live streams.
// features/todos/todos.commands.ts — the write side
import { command } from "../../belt/http.ts"
import { shape, str, parse } from "../../belt/shape.ts"
import { formErrors } from "../../belt/ds.ts"
import { toast } from "../patterns/toast.ts"
const todoShape = shape({
title: str().trim().min(1, "Title is required").max(200),
})
export const create = command(async (ctx, respond) => {
const result = await parse(todoShape, ctx.signals)
if (!result.ok) {
// Requester-only feedback, into the spans formFor rendered.
respond.patch(formErrors("todo", todoShape, result.issues))
return
}
const user = currentUser(ctx)
sql.insert(ctx.store, result.value.title, user?.id ?? null)
ctx.bus.publish("todos", { type: "todos.created" }) // → every watcher re-renders
respond.signals({ title: "" }) // reset the requester's form
toast(respond, "Todo added", { tone: "success" })
})
export const destroy = command(async (ctx, respond) => {
const id = Number(paths.todos.item.params(ctx).id)
const user = currentUser(ctx)
if (!user || !authz.can(ctx.store, user.id, "delete", `todos:${id}`)) {
toast(respond, "Only the creator or an admin can delete a todo.", { tone: "error" })
closeModal(respond)
return
}
sql.remove(ctx.store, id)
ctx.bus.publish("todos", { type: "todos.deleted" })
closeModal(respond)
})
Note the resource-level authorization: deleting todo 42 requires a grant matching
("delete", "todos:42"). The create command writes that grant
when it inserts the row — through the auth slice's exported SQL, never by touching auth's
tables directly. Cross-slice writes are choreography through the owner's API. The delete
button appearing in the DOM is a courtesy; the command re-checks regardless.
The routes file: the public face
The routes module is the slice's manifest — everything the app needs to mount the feature is exported here. It wires the view and command exports into a typed HTTP resource and declares the live region: the model function that computes what each viewer sees, the topics that wake it, and the ephemeral stores it watches. The live region's model is per-viewer — the deletable ids list differs by who is looking — which is exactly what makes different viewers see different DOM without any client session state.
// features/todos/todos.routes.ts — the resource manifest
import { resource, view } from "../../belt/http.ts"
import { region } from "../../belt/region.ts"
export const todos = {
name: "todos",
migrations: sql.migrations,
routes: [
...resource(paths.todos.base.pattern, {
// GET /todos — server-rendered, works before JS loads
index: view((ctx) =>
TodoPage(sql.all(ctx.store), viewerNames(), currentUser(ctx), canDelete(ctx))
),
// GET /todos/live — the long-lived SSE stream
live: region(
{
topics: ["todos", "flags", "masquerade"],
watch: [viewers, authz, masquerades],
enter: joinViewers, // register presence, return cleanup
model: (ctx) => {
const items = sql.all(ctx.store)
const user = currentUser(ctx)
return {
items,
viewers: viewerNames(),
deletable: user
? items
.filter((t) => authz.can(ctx.store, user.id, "delete", `todos:${t.id}`))
.map((t) => t.id)
: [],
}
},
},
(m) => TodoList(m.items, m.viewers, (t) => m.deletable.includes(t.id)),
),
create: commands.create, // POST /todos
update: commands.update, // PATCH /todos/:id
destroy: commands.destroy, // DELETE /todos/:id
}),
],
}
Open it in two browser windows. Add a todo in one; it appears in the other in under a frame. That's the entire multiplayer story — no sockets to manage, no client store to reconcile. The region re-renders every time "todos" fires, and both windows have an open stream.
Slice discipline and the dependency graph
The import graph has a named shape, and it runs one direction only:
- The lining — auth, journal: what every app has, sitting under everything else.
Anyone may depend on the lining; the lining never depends on a domain slice. The journal
exports a
record()function; auth exports guards and query functions. These are the published contracts. - Domain slices — todos, reports: the reason the app exists. May depend on the lining, never on each other. A domain slice that needs data from another domain slice is either a choreography problem (events, jobs) or a sign that a third slice is missing.
- Window slices — admin: read-only composition over other slices' exported queries. Owns no tables. Nothing depends on it. Data travels across the seam; presentation, URLs, and gates belong to the consumer.
Cross-slice writes are choreography, not direct calls. When create inserts a
todo, it calls authSql.addUserGrant(...) — the auth slice's exported function —
and publishes an "authz" event. The auth slice reacts in its own
init(). Slices hand off through messages and published contracts, never through
each other's tables. The database enforces it: a migration touching another feature's table
throws at boot, entry rolled back.
Cross-slice reads follow a ladder. Most composition is a layout problem — a page
is several live regions, each owned by a different feature streaming its own fragment, wired
by two data-init attributes. When one region genuinely needs to interleave data
from two slices, the composition lives in model(): explicit, auditable, and the
region dirty-checks the composed result. The join migrates to the owning feature as an
exported function, never to the boundary.
tags feature touches the todos table,
orion throws at boot:
"migration ownership violation: feature "tags" migration #0 touches table "todos",
owned by feature "todos". A table belongs to exactly one feature's migration list — read it
through the owner's exported queries instead."
The machine checks this against sqlite_master, not against SQL parsing. No
convention to forget; the oracle is ground truth.
Commands are protocol adapters
A command handler is deliberately a protocol adapter — not the operation itself. Its job is
the hypermedia conversation: read signals from the request, run the operation, answer with
requester-scoped feedback. While a command body is parse → owned SQL calls → publish →
feedback, inline is correct. The moment an operation grows real invariants or a second
caller appears — a job handler, a CLI script, another command — extract it as a plain
exported function and let the handler call it. The signature is always the same:
{ store, bus, log } plus input, which is exactly what every handler already
receives. No mediator needed; module imports of plain functions are the most boring possible
dispatch.
What orion will not do: a single dispatch endpoint with a command name in the body.
That erases the hypermedia map — every intent is a route, and orion routes
prints your domain because it is. Per-route authorization, per-route logging,
per-route typing all follow from that.
A feature is one folder, four files — sql / view / commands / routes.
No logic smeared across a models directory, no cross-feature table writes, no ambient
dependencies. The dependency graph stays acyclic because edges only cross through a slice's
published contract. orion lint is the mechanical check.