Skip to content

Request Lifecycle

Semitra uses a fixed request path:

Request -> Router -> Controller -> Policy -> Model -> Events -> Resource -> Response

This is the core framework contract. Each subsystem owns one job.

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.

SemitraApp.fetch() builds a SemitraRequestContext that carries:

  • request
  • env
  • ctx
  • 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);
}
}

The controller coordinates request execution. It can:

  • run beforeAction and afterAction hooks
  • validate params
  • set currentUser
  • authorize or policy-scope work
  • select models and resources
  • render a response

SemitraController is orchestration, not persistence or serialization.

Policies decide whether an action is allowed and how scopes are filtered.

Semitra keeps policy checks explicit. In the example app:

  • authorize() is used on show, create, update, and destroy
  • policyScope() is used on index
  • controller guards like verifyAuthorized() and verifyPolicyScoped() 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";
}
}

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.

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.

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.

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.

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