Request Lifecycle
Semitra uses a fixed request path:
Request -> Router -> Controller -> Policy -> Model -> Events -> Resource -> ResponseThis is the core framework contract. Each subsystem owns one job.
1. Router
Section titled “1. Router”Routes are declared in config/routes.ts with the Semitra DSL:
import { drawRoutes, get, namespace, post, resources } from "@semitra/cli";
export default drawRoutes(() => { resources("posts"); get("health", "health#show"); post("rpc", "rpc#create");
namespace("api", () => { namespace("v1", () => { resources("posts"); get("health", "health#show"); }); });});In the example app, those routes map a small set of controllers onto the versioned API surface instead of spreading request handling across ad hoc functions.
The router resolves a controller name, action name, and route metadata. That metadata becomes part of the request context.
2. Request context
Section titled “2. Request context”SemitraApp.fetch() builds a SemitraRequestContext that carries:
requestenvctx- resolved adapters
- route metadata
- params
- validated params
- current tenant
locals- a request-scoped container
This is the runtime object shared by controllers and other subsystems.
For a posts endpoint, that context lets a controller bind the model, authorize the request, and render a resource without reaching into platform bindings directly:
import ApplicationController from "../../application_controller.ts";import Post from "../../../models/post.ts";
export default class PostsController extends ApplicationController { async show() { const Posts = this.model(Post); const post = await Posts.find(String(this.params.get("id")));
await this.authorize(post, "show"); return this.renderResource(post); }}3. Controller
Section titled “3. Controller”The controller coordinates request execution. It can:
- run
beforeActionandafterActionhooks - validate params
- set
currentUser - authorize or policy-scope work
- select models and resources
- render a response
SemitraController is orchestration, not persistence or serialization.
4. Policy
Section titled “4. Policy”Policies decide whether an action is allowed and how scopes are filtered.
Semitra keeps policy checks explicit. In the example app:
authorize()is used onshow,create,update, anddestroypolicyScope()is used onindex- controller guards like
verifyAuthorized()andverifyPolicyScoped()ensure the expected checks actually ran
The reference policy stays intentionally small:
import type Post from "../models/post.ts";import ApplicationPolicy from "./application_policy.ts";
type ExampleUser = { id: string; role: string;};
export default class PostPolicy extends ApplicationPolicy< Post | typeof Post, ExampleUser | null> { show(): boolean { return true; }
create(): boolean { return true; }
update(): boolean { return true; }
destroy(): boolean { return this.user?.role === "admin"; }}5. Model
Section titled “5. Model”SemitraRecord reads and writes persistent state through runtime adapters.
Records own:
- schema-backed attributes
- queries
- associations
- validations
- lifecycle hooks
- attachments
Records do not own response formatting. That is a resource concern.
6. Events and jobs
Section titled “6. Events and jobs”Once the model layer changes state, side effects can move into:
- events through
SemitraEvents - jobs through
SemitraJob - mailers and mailboxes
- realtime channels
This keeps controller actions thin and separates edge request work from queued or broadcast work.
7. Resource
Section titled “7. Resource”Resources define the JSON shape exposed to clients. They turn records into response payloads with:
- declared attributes
- computed attributes
- relationships
- optional response validation
The example resource for posts exposes a computed excerpt and declares a comments relationship:
import BaseApplicationResource from "./application_resource.ts";import CommentResource from "./comment_resource.ts";
export default class PostResource extends BaseApplicationResource<Post> { static { this.attributes("id", "title", "content", "createdAt"); this.attribute("excerpt", (post) => typeof post.content === "string" ? post.content.slice(0, 32) : null ); this.hasMany("comments", () => CommentResource); }}The reference app includes CommentResource to demonstrate relationship
serialization. A production app should add the matching record, migration, and
association when comments are part of the real domain model.
8. Response
Section titled “8. Response”Semitra returns Web Standard Response objects. Controllers can render:
- JSON
- plain text
- CSV
- binary data
- empty responses with
head(status)
The create action shows the same pattern in practice:
if (!(await post.save())) { return this.renderErrors(post.errors);}
return this.renderResource(post, { status: 201 });That final response is what leaves the Worker.
Why this order matters
Section titled “Why this order matters”Semitra works best when subsystem boundaries stay intact:
- do not put authorization in records
- do not serialize inside controllers by hand when a resource exists
- do not hide platform bindings inside arbitrary helper layers
- do not duplicate subsystem responsibilities