Feature folders
The unit of modularity in a Norns app — a bounded domain with a module, a repo, a service, a facade and a shared schema.
A feature is a folder that owns one domain. Its files have fixed roles:
| File | Role | Who may import it |
|---|---|---|
server/module.c |
Registers the feature's DI bindings (and migration dirs). The only file boot() reaches. |
the runtime |
server/repo.c |
Data access. The only place SQL lives. | service.c |
server/service.c |
Business rules. No SQL, no HTTP. | public.c |
server/public.c |
The facade: the feature's public API. | routes and other features |
shared/schema.c |
valibot schemas shared by server and client. | routes, components, service.c |
ui/*.n |
Components that belong to the feature. | routes |
Routes and other features call a feature only through its facade. That rule is what makes a feature movable, testable with an overridden container, and safe for an agent to edit: the graph of who calls whom is explicit.
#The starter feature, in full
norns-app ships src/lib/norns/messages/, an in-memory message board. It is the whole pattern in forty lines.
server/repo.c:
export class MessagesRepo
items: { id: number; text: string; at: number }[] = []
list()
[...@items]
add(text: string)
item := { id: @items.length + 1, text, at: Date.now() }
@items.unshift item
itemserver/service.c:
import type { MessagesRepo } from './repo'
import type { SendMessageInput } from '../shared/schema'
export class MessagesService
repo: MessagesRepo
constructor(@repo: MessagesRepo)
list()
@repo.list()
create(input: SendMessageInput)
@repo.add input.textserver/module.c registers the tokens. The convention is <feature>.<role>:
import type { Container } from '@human-synthesis/norns/server'
import { MessagesRepo } from './repo'
import { MessagesService } from './service'
export default (app: Container) =>
app.single 'messages.repo', => new MessagesRepo!
app.single 'messages.service', (c: Container) => new MessagesService c.resolve('messages.repo')server/public.c is what everyone else imports. It takes the request's container and returns the operations:
import type { Container } from '@human-synthesis/norns/server'
import type { MessagesService, SendMessageInput } from './service'
svc := (c: Container) => c.resolve('messages.service') as MessagesService
export messages := (c: Container) => {
list: () => svc(c).list()
create: (input: SendMessageInput) => svc(c).create(input)
}And a route uses it through the page wrappers:
// src/routes/+page.server.c
import { messages } from '$lib/norns/messages/server/public'
import { sendMessageSchema } from '$lib/norns/messages/shared/schema'
export load := page.load
handler: ({ container }) =>
items: messages(container).list()
export actions := page.actions
send:
input: sendMessageSchema
run: ({ input, container }) =>
messages(container).create input
{ ok: true }#Adding a feature
- Create
src/lib/norns/<feature>/server/withmodule.c,repo.c,service.c,public.c, andshared/schema.cif the feature validates input. - Register bindings in
module.c; useapp.singlefor process-wide instances andapp.bindfor per-resolve instances (for example a request-scoped D1 handle, see Cloudflare D1). - Import the facade from routes. Never import
service.correpo.cfrom outside the folder. - Migrations go to
migrations/<feature>/, created withnorns migrate create <feature>/<name>.
The import.meta.glob in hooks.server.c picks the new module up automatically; nothing else registers it.