The conventions
The conventions that turn araara's libraries into a framework — taught as a tutorial that builds one small application file by file: a helpdesk with real logins, roles, a live htmx UI and a JSON API, all from the same business logic. Every code block is the actual source of the file named above it.
Libraries give you capabilities; conventions give you a codebase your whole team can navigate. To make them concrete, this page is a tutorial: we build one small application — a helpdesk where customers open and follow their own support tickets and operators triage everyone's — file by file, and name each convention as it earns its place. It has real logins, roles, a live htmx UI and a JSON API. Every OCaml block below is the actual source of the file named above it (from examples/helpdesk/), so it compiles and runs in the test suite — the tutorial can never drift from the code.
An application is a layout
Every araara application splits in two: a core that knows nothing about HTTP, and a web layer that depends on the core, never the reverse. The core holds the model and the business logic; the web layer is just one of its consumers. Drop the web library and the same core powers a CLI, a desktop app, or a background worker — and, just as usefully, you can test the whole domain without starting a server.
examples/helpdesk/
├── bin/
│ └── main.ml -- boot: migrate, seed, serve
├── lib/
│ ├── helpdesk/ -- CORE (library helpdesk_core): no HTTP
│ │ ├── db.ml -- the driver-bound repo
│ │ ├── role.ml -- Customer | Operator
│ │ ├── models/ -- one file per table
│ │ │ ├── account.ml
│ │ │ ├── ticket.ml
│ │ │ └── reply.ml
│ │ ├── contexts/ -- business logic + authorization
│ │ │ ├── accounts.ml
│ │ │ └── tickets.ml
│ │ └── migrations.ml -- ordered migration list
│ └── helpdesk_web/ -- WEB (library helpdesk_web): over the core
│ ├── routes.ml -- typed paths, single source of truth
│ ├── router.ml -- pipelines + scopes
│ ├── endpoint.ml -- static assets + router
│ ├── plugs/auth.ml -- session gate; the current account
│ ├── views/ -- pure-html pages and fragments
│ │ ├── layout.ml
│ │ ├── tickets.ml
│ │ └── auth.ml
│ ├── controllers/web.ml -- HTML actions
│ └── api/tickets.ml -- the JSON twin
├── priv/static/app.css -- the stylesheet (served as a static asset)
└── test/test.ml -- testo context testsThat is the whole application — the tree above is its real directory layout, two libraries: helpdesk_core (models and contexts) and helpdesk_web (views, controllers, an api, routes, a router and plugs) that depends on it, with a bin/ that serves, priv/static for the stylesheet, and a test/ suite. Each file does one small thing — often only ten or twenty lines — and every code block below names the file it comes from, so the page doubles as a walk through the project. We build it in dependency order: core first, then the web layer over it.
Five layers, one direction
Inside that split, every feature flows the same way: Model → Context → Controller or Api → View → Router. Data flows up, dependencies point down, and nothing skips a layer. Hold this shape in mind; the rest of the page is one trip through it.
- Model — A record plus its repodb schema, changeset and named queries. One model per table, no business logic.
- Context — The public API of a bounded subdomain. Business rules, transactions and side effects live here, with uniform naming:
list_*,get_*,create_*,update_*,delete_*. - Controller / Api — Thin HTML and JSON transports over the same context. If logic appears here, it gets lifted into the context.
- View — A pure function from data to HTML nodes. No I/O, no auth checks, no database — just pure-html.
- Router — Pipelines and scopes. Sessions, CSRF, auth, rate limits and security headers are pipeline plugs, never handler code.
The model: the table, and the only door to the data
A model is one record, its table, and the typed queries over it — one model per table, and the only layer that talks to the database. Start with the data. A ticket's status is a three-way variant, not a string, so no controller, view or query downstream can ever be handed a status that does not exist — illegal states are unrepresentable. The migration describes the schema in portable typed columns, never a CREATE TABLE string.
(* The domain type. [status] is a three-way variant, not a string, so no
layer downstream can be handed a status that cannot exist. *)
type status = Open | Pending | Closed
type t = { id : int; requester_id : int; subject : string; body : string; status : status }
let status_to_string = function Open -> "open" | Pending -> "pending" | Closed -> "closed"
let status_of_string = function "pending" -> Pending | "closed" -> Closed | _ -> Open
let table = Repodb.Schema.table "tickets"
let field name ty get set = Field.make ~table_name:"tickets" ~name ~ty ~get ~set ()
let id_f = field "id" Types.int (fun t -> t.id) (fun v t -> { t with id = v })
let requester_f =
field "requester_id" Types.int (fun t -> t.requester_id) (fun v t -> { t with requester_id = v })
let subject_f = field "subject" Types.string (fun t -> t.subject) (fun v t -> { t with subject = v })
let body_f = field "body" Types.string (fun t -> t.body) (fun v t -> { t with body = v })
let status_f =
field "status" Types.string
(fun t -> status_to_string t.status)
(fun v t -> { t with status = status_of_string v })
let migration =
let open Repodb.Migration in
migration ~version:2L ~name:"create_tickets" ~down:[ drop_table "tickets" ]
~up:
[
create_table "tickets"
[
typed_column "id" Types.int ~primary_key:true;
typed_column "requester_id" Types.int ~nullable:false;
typed_column "subject" Types.string ~nullable:false;
typed_column "body" Types.string ~nullable:false;
typed_column "status" Types.string ~nullable:false;
];
]Validation lives with the model too, as a changeset. cast copies only the fields you whitelist, so an extra parameter is dropped rather than smuggled into a write (mass-assignment, closed by default); the validators then accumulate every failure at once. By the time data leaves the changeset it is known-good, and the error list is ready for a form or a JSON response.
(* CHANGESET — cast whitelists the fields a client may set (neither id nor
requester_id can be smuggled in from a form), then the validators
accumulate every failure at once. *)
let changeset params =
Changeset.create { id = 0; requester_id = 0; subject = ""; body = ""; status = Open }
|> Changeset.cast params ~fields:[ subject_f; body_f ]
|> Changeset.validate_required [ subject_f; body_f ]
|> Changeset.validate_length subject_f ~min:4 ~max:120Queries go through the DSL, never raw SQL
Every read and write the model performs is built with repodb's query DSL, which the driver compiles to prepared statements. That is what keeps a query type-checked against the schema, safe from injection, and portable across SQLite, PostgreSQL and MariaDB — so hand-written SQL strings stay out of application code. (repodb keeps a raw-SQL escape hatch for the rare query the DSL cannot express; the repodb page covers when to reach for it.) These four functions are the model's entire contract with the database — and decode is the one place a row becomes a record.
(* QUERIES — every read and write through the Query DSL. [all] and
[for_requester] are the two reads authorization picks between. *)
let decode row =
{
id = Repodb.Driver.row_int row 0;
requester_id = Repodb.Driver.row_int row 1;
subject = Repodb.Driver.row_text row 2;
body = Repodb.Driver.row_text row 3;
status = status_of_string (Repodb.Driver.row_text row 4);
}
let all conn = Db.Repo.all_query conn (Query.from table |> Query.desc (Expr.column id_f)) ~decode
let for_requester conn ~requester_id =
Db.Repo.all_query conn
(Query.from table
|> Query.where Expr.(column requester_f = int requester_id)
|> Query.desc (Expr.column id_f))
~decode
let get conn ~id = Db.Repo.get conn ~table ~id ~decode
let insert conn ~requester_id ~subject ~body =
let q =
Query.insert_into table
|> Repodb.Query_values.values4 (requester_f, subject_f, body_f, status_f)
( Expr.int requester_id,
Expr.string subject,
Expr.string body,
Expr.string (status_to_string Open) )
in
match Db.Repo.insert_query conn q with
| Error e -> Error e
| Ok () -> (
match Db.last_insert_id conn with
| Error e -> Error (Repodb.Error.Query_failed (Repodb_sqlite.error_message e))
| Ok id -> get conn ~id:(Int64.to_int id))
let set_status conn ~id ~status =
Db.Repo.update_query conn
(Query.update table
|> Query.set status_f (Expr.string (status_to_string status))
|> Query.where Expr.(column id_f = int id))The context: where the domain lives
The context is the public face of a bounded subdomain — Tickets here, Accounts or Billing elsewhere — and the one home for business logic. It works through the model: it calls the model's queries and never builds a query or touches the database itself. Three conventions show up in create — it owns a single transaction; it returns a result with a named error variant, never raising; and the pubsub broadcast, a domain event, fires only after that transaction commits, never inside it. Capabilities (the connection, the bus) are passed in explicitly, so the same function runs against a real database or an in-memory test one.
(* An operator sees every ticket; a customer only their own. *)
let visible conn ~(viewer : Models.Account.t) =
let r =
if Role.is_operator viewer.role then Models.Ticket.all conn
else Models.Ticket.for_requester conn ~requester_id:viewer.id
in
match r with Ok ts -> ts | Error _ -> []
let create conn ~pubsub ~(requester : Models.Account.t) ~params =
let cs = Models.Ticket.changeset params in
if not (Changeset.is_valid cs) then Error (`Validation (Changeset.error_messages cs))
else
let value f = Option.value (Changeset.get_change cs f) ~default:"" in
let subject = value Models.Ticket.subject_f and body = value Models.Ticket.body_f in
let insert c = Models.Ticket.insert c ~requester_id:requester.id ~subject ~body in
match Db.transaction conn insert with
| Error e -> Error (`Db e)
| Ok t ->
(* Post-commit: the ticket is durable, so fan out a domain event
carrying its id (the live operator feed is one subscriber). *)
pubsub.Pubsub.publish ~topic:"tickets" ~payload:(string_of_int t.Models.Ticket.id);
Ok t
(* Resolving (and any status change) is operator-only. *)
let triage conn ~(viewer : Models.Account.t) ~id ~status =
if not (Role.is_operator viewer.role) then Error `Forbidden
else
match Db.transaction conn (fun c -> Models.Ticket.set_status c ~id ~status) with
| Error e -> Error (`Db e)
| Ok () -> ( match Models.Ticket.get conn ~id with Ok t -> Ok t | Error _ -> Error `Not_found)Authorization is business logic too, so it lives here, not in the controllers. The read is scoped by the viewer's role — an operator sees every ticket, a customer only their own — and triage is operator-only, returning a Forbidden value a customer's request cannot get past. Decide who may do what in one place, and every transport inherits the same answer.
Everything after this point is transport. Two callers — a browser and an API client — will reach this same create, and neither will re-implement a single rule.
Authentication: a gate in the pipeline
Authorization (above) decides what a known user may do; authentication decides who they are — and that belongs in the pipeline, not in handlers. A successful login stores the account id in the session; the require_login plug then does the work once per request: it loads the account and attaches it to the request with Request.with_assign, turning away anything unauthenticated before a controller runs. Handlers just read that viewer back from the request — they never touch the session or the database to find out who is calling. The API twin gets the same plug, answering 401 instead of redirecting.
(* A typed key for the account, set by the plug below and read by handlers. *)
let account_key : Models.Account.t Hcs.Assigns.key = Hcs.Assigns.create_key ()
(* The account the plug attached to this request. Safe behind the gate. *)
let viewer req =
match Hcs.Request.assign req account_key with
| Some a -> a
| None -> failwith "viewer: route is not behind require_login"
let resolve conn =
match Session.get "uid" with
| None -> None
| Some s ->
Option.bind (int_of_string_opt s) (fun id ->
match Models.Account.get conn ~id with Ok a -> Some a | Error _ -> None)
let require_login conn handler req =
match resolve conn with
| Some a -> handler (Hcs.Request.with_assign req account_key a)
| None -> Hcs.Response.found "/login"
let require_api conn handler req =
match resolve conn with
| Some a -> handler (Hcs.Request.with_assign req account_key a)
| None -> Hcs.Response.json ~status:`Unauthorized {|{"error":"login required"}|}Views: pure functions to HTML
A view is a pure function from data to HTML nodes — no I/O, no auth checks, no database, just pure-html. The trick that makes HTML-over-the-wire work is small: one ticket_li function renders both inside the queue page and as the fragment a write returns, so the list and an htmx swap can never fall out of sync.
(* One view, reused two ways: inside the queue page, and as the fragment a
create returns (appended to the list) or the SSE feed pushes. The whole
row links to the ticket's conversation, where the work happens. *)
let ticket_li ~(viewer : Models.Account.t) (t : Models.Ticket.t) =
li
[ id "ticket-%d" t.id; class_ "ticket" ]
[
a [ class_ "subject"; href "%s" (url (Routes.ticket ()) t.id) ] [ txt "%s" t.subject ];
(if Role.is_operator viewer.role then span [ class_ "who" ] [ txt "from #%d" t.requester_id ]
else null []);
span [ class_ "badge %s" (Models.Ticket.status_to_string t.status) ]
[ txt "%s" (Models.Ticket.status_to_string t.status) ];
]Controllers: a full page, or a portion of one
Controllers are thin. index renders a complete page; create and triage return a portion — the single row htmx swaps into place without a reload. Both just call the context and hand its result to a view: there is no business logic to find here, which is exactly the point. An invalid submission becomes a 422 carrying the changeset's own messages.
(* Captures arrive as typed arguments (the [id : int] below comes from the
route, no param lookup). [index] returns a COMPLETE page; [create] and
[triage] return a PORTION the htmx swaps in. The viewer comes from the
plug; the rules from the context. *)
let index conn req =
let viewer = Plugs.Auth.viewer req in
respond (Views.Tickets.index_page ~viewer ~tickets:(Contexts.Tickets.visible conn ~viewer))
let show conn id req =
let viewer = Plugs.Auth.viewer req in
match Contexts.Tickets.show conn ~viewer ~id with
| Ok detail -> respond (Views.Tickets.detail_page ~viewer detail)
| Error `Forbidden -> Hcs.Response.forbidden ~body:"not your ticket" ()
| Error `Not_found -> Hcs.Response.not_found ~body:"no such ticket" ()
| Error _ -> Hcs.Response.found (url (Routes.home ()))
let create conn pubsub req =
let requester = Plugs.Auth.viewer req in
match Contexts.Tickets.create conn ~pubsub ~requester ~params:(Hcs.Request.parse_query_string (Hcs.Request.body req)) with
| Ok t -> respond (Views.Tickets.ticket_li ~viewer:requester t)
| Error (`Validation msgs) -> Hcs.Response.unprocessable_entity ~body:(String.concat "; " msgs) ()
| Error _ -> Hcs.Response.unprocessable_entity ~body:"could not open ticket" ()
let triage conn id to_ req =
let viewer = Plugs.Auth.viewer req in
match Contexts.Tickets.triage conn ~viewer ~id ~status:(Models.Ticket.status_of_string to_) with
| Ok t -> respond (Views.Tickets.status_region ~viewer t)
| Error `Forbidden -> Hcs.Response.forbidden ~body:"operators only" ()
| Error `Not_found -> Hcs.Response.not_found ~body:"no such ticket" ()
| Error _ -> Hcs.Response.unprocessable_entity ~body:"could not update" ()Web/API parity: one context, two transports
When there is a real API consumer — a script, a bot, another service filing tickets — it gets a JSON twin under /api/v1. The twin calls the very same Tickets.create, so its validation and its post-commit broadcast are identical to the web form's. The API is never an afterthought because it is never separate code; only the encoding at the edge differs.
let codec =
let open Simdjsont.Decode in
Obj.field (fun id subject status ->
{ Models.Ticket.id; requester_id = 0; subject; body = ""; status = Models.Ticket.status_of_string status })
|> Obj.mem "id" int ~enc:(fun (t : Models.Ticket.t) -> t.id)
|> Obj.mem "subject" string ~enc:(fun (t : Models.Ticket.t) -> t.subject)
|> Obj.mem "status" string ~enc:(fun (t : Models.Ticket.t) -> Models.Ticket.status_to_string t.status)
|> Obj.finish
let index conn req =
let viewer = Plugs.Auth.viewer req in
Hcs.Response.json
(Simdjsont.Encode.to_string (Simdjsont.Decode.list codec) (Contexts.Tickets.visible conn ~viewer))
let create conn pubsub req =
let requester = Plugs.Auth.viewer req in
let params = Hcs.Request.parse_query_string (Hcs.Request.body req) in
match Contexts.Tickets.create conn ~pubsub ~requester ~params with
| Ok t -> Hcs.Response.json ~status:`Created (Simdjsont.Encode.to_string codec t)
| Error err ->
let msgs = match err with `Validation m -> m | _ -> [ "could not open ticket" ] in
let body = Printf.sprintf {|{"errors":[%s]}|} (String.concat "," (List.map (Printf.sprintf "%S") msgs)) in
Hcs.Response.unprocessable_entity ~body () |> Hcs.Response.with_header "Content-Type" "application/json"Live updates: one broadcast, every operator
Recall the context broadcast a domain event after the ticket committed. Here is its consumer. An operator's queue holds open a Server-Sent Events connection; the handler subscribes to the tickets topic, and every new ticket becomes a row pushed down the stream that htmx prepends to the list. A customer opening a ticket shows up on every operator's screen at once — no polling, no reload — and the subscription is released when the connection drops. The write path never knew any of this existed; it only published a fact.
(* LIVE UPDATES — the context's post-commit broadcast, consumed. An operator
holds open this SSE connection; each create event becomes the new ticket
row pushed down the stream, which htmx prepends to the queue. The
subscription is released when the connection drops. *)
let events conn pubsub req =
let viewer = Plugs.Auth.viewer req in
if not (Role.is_operator viewer.role) then Hcs.Sse.respond (fun () -> None)
else
let inbox = Eio.Stream.create 64 in
let unsubscribe = pubsub.Hive.Pubsub.subscribe ~topic:"tickets" (fun ~payload -> Eio.Stream.add inbox payload) in
Hcs.Sse.respond (fun () ->
match Eio.Stream.take inbox with
| exception (Eio.Cancel.Cancelled _ as e) -> unsubscribe (); raise e
| payload -> (
match Option.bind (int_of_string_opt payload) (fun id -> Result.to_option (Models.Ticket.get conn ~id)) with
| Some t -> Some (Hcs.Sse.make ~event_type:"new-ticket" (to_string (Views.Tickets.ticket_li ~viewer t)))
| None -> Some (Hcs.Sse.make ~event_type:"ping" "")))Routes: typed and bidirectional
A route is one value that does two jobs: it registers a handler and it builds URLs. Captures are typed — tickets / int makes a route that hands its handler an int, and the matching Route.link demands an int to build the path. A controller never looks a parameter up by string and never parses it; a view never interpolates a path by hand. Change a route's shape and every handler and link that disagrees stops compiling, so a broken URL is a build error, not a 404 in production.
let home () = R.s ""
let login () = R.s "login"
let logout () = R.s "logout"
let events () = R.s "events"
let tickets () = R.s "tickets"
let ticket () = R.(s "tickets" / int)
let ticket_replies () = R.(s "tickets" / int / s "replies")
let ticket_status () = R.(s "tickets" / int / s "status" / str)The router: pipelines and scopes
Cross-cutting request concerns — logging, recovery, sessions, CSRF, auth, rate limits, security headers — are plugs composed into named pipelines, never code inside a handler. A scope declares which pipeline its routes run through, so the security posture of every URL is readable in one place. Here the HTML routes and their /api/v1 twins sit side by side, each pointed at the same context.
R.compile_scopes
[
R.scope "/" ~through:base
[ Rt.get (Routes.login ()) Controllers.Web.login_form; Rt.post (Routes.login ()) (Controllers.Web.login conn) ];
R.scope "/" ~through:web
[
Rt.get (Routes.home ()) (Controllers.Web.index conn);
Rt.post (Routes.logout ()) Controllers.Web.logout;
Rt.post (Routes.tickets ()) (Controllers.Web.create conn pubsub);
Rt.get (Routes.ticket ()) (Controllers.Web.show conn);
Rt.post (Routes.ticket_replies ()) (Controllers.Web.reply conn);
Rt.post (Routes.ticket_status ()) (Controllers.Web.triage conn);
Rt.get (Routes.events ()) (Controllers.Web.events conn pubsub);
];
R.scope "/api/v1" ~through:api
[
Rt.get (Routes.tickets ()) (Api.Tickets.index conn);
Rt.post (Routes.tickets ()) (Api.Tickets.create conn pubsub);
];
]The rules that hold it together
Step back and the conventions are a short list of rules — each one you just saw the helpdesk obey:
- Illegal states are unrepresentable — Domain types are parsed, not validated: a ticket's status is a variant, not a string, so no layer ever has to handle a status that cannot exist.
- Errors are values — Workflows return result with named error variants — the context's create returns the changeset's error list, never an exception. Exceptions are caught only at the boundary that can translate them.
- Authenticate at the edge, authorize in the context — Whether there is a logged-in user is a pipeline plug; what that user may see or do is a rule in the context, returning Forbidden as a value. Controllers carry neither concern, so the web route and its API twin enforce the same access by construction.
- I/O at the edges — Contexts perform effects; the decisions around them are pure functions you can call in a test. Capabilities — the clock, the connection, the pubsub bus — are passed in explicitly, never reached for globally.
- HTML over the wire — Server-rendered fragments swapped by htmx; alpinejs only for local UI state. JSON appears when there is a real API consumer — and then as a twin of the web route, backed by the same context.
- One transactional boundary per write — A public context write owns its transaction. Post-commit effects — the pubsub broadcast, a background job — are wired after it commits, never inside.
- Everything supervised — Long-lived work runs as Eio fibers under one switch, started by application.ml — one root for the whole supervision tree, so shutdown is orderly by construction.
See it run
The test suite calls the context directly against an in-memory database — no server, no HTTP — and asserts the rules you just read: customers see only their tickets, only operators triage, a too-short subject is rejected, and one customer cannot reach another's ticket. That the business logic is this easy to test is the payoff of keeping it in the context:
$ dune build @examples/helpdesk/test/runtestAnd to use the app yourself, run the binary and open it in a browser — sign in as ada / secret (a customer) or sam / secret (an operator):
$ dune exec examples/helpdesk/bin/main.exeFor the same conventions at full scale — many contexts, a real database, background workers and a clustered deployment — read ahoj, a complete application built exactly this way.