Django views the right way, translated to Mojo, 2026-09-10
A design note from the engineering record. Prompted by reading The Pattern and asking what it should be built out of here instead of Python.
The article's claim is narrow and worth taking literally. A view is a
function that takes a request and returns a response; the recommended
starting point makes all three visible at a glance, and class-based views
hide all three behind .as_view(). Its Django form is:
def example_view(request: HttpRequest, arg: str) -> HttpResponse:
return TemplateResponse(request, 'example.html', {})
Three of the four mechanisms that shape make use of do not exist in Mojo:
module-level globals, dict[str, Any], and a decorator that returns a
wrapped callable. Working through what replaces each is most of this note.
The fourth — a plain function in a URL table — ports exactly, and the tree
was not using it.
What the tree did instead
Router answers which route matched, as an integer, and turning that
integer back into code was every app's own business. All three Mojo apps
did it the same way: a comptime H_GET = 2 block, a
router.add("GET", "/notes/:id", H_GET) call, and an
if m.handler_id == H_GET: return self._get_one(...) chain — the URL and
the code that answers it three edits apart, with nothing checking that they
agree. The views themselves were private methods on a struct that also
owned the router, the CORS config, the health registry and the data store.
That is not quite a class-based view, but it loses the article's property the same way: you cannot see what answers a URL without reading three places, and the third is a chain.
It was also unsafe in a way worth recording. apps/notes_api ended its
chain with a bare return self._delete(req, id) as the fallthrough, so a
route added without a matching arm did not 404 — it deleted. The
conversion below removes the chain; dispatch has no fallthrough to
reach, and test_views.mojo pins what it answers instead. (A smoke-notes
assertion was first described as guarding this; it cannot, because the old
chain answered 405 before the chain for any unregistered method.)
The pattern
A view is a free function. The table names it directly, and says both whether it writes and where it runs:
def detail(
req: HTTPRequest, params: List[String], store: NoteStore
) raises -> HTTPResponse:
var page = store.note_page(reply.param_int(params[0]))
if not page:
return _missing(req.uri.path)
return reply.html(render_note(page.value()))
...
v.add_loop(String("GET"), String("/health"), health)
v.add_read(String("GET"), String("/notes/:id"), detail)
v.add_write(String("POST"), String("/notes"), create)
Views[S] (packages/m0-http/src/views.mojo) is a Router and a parallel
List of view functions. add assigns the handler id itself, so an app
never sees one and cannot get one wrong; dispatch matches and calls. The
integer still exists and still indexes an allocation-free byte scan —
Router is untouched — but it is now an implementation detail of one
struct rather than a constant an app maintains.
Two apps are the worked examples, and both were converted under a green
smoke rather than written fresh, so the wire output is known not to have
moved. apps/notes_api keeps its handler struct — it uses after_response
for CORS and owns a static mount and a health registry, hooks the table
does not cover — and calls Views.dispatch from a func with no chain in
it. apps/fragment_notes needs no hook at all, so ViewService is its
handler and main is the table, the store, and serve. A first draft of
this note came with a third notes app, apps/views_pattern, built to show
the shape; it was dropped rather than landed, because the tree does not
need three notes apps and the shape is shown by the two that exist.
Three things the first draft got wrong
The shape above is the second version. The first had one table, one view type, and an app-written handler struct; each of the three changes came out of asking what the framework already knew that the table did not say.
The shell was ceremony. An app wrote a struct that held the table, held
the state and delegated to it — which Django never asks anyone to write.
A parametric struct can conform to HTTPService (probed; the bound is
S: Movable & Deinitable, and listen_and_serve_nonblocking[T: HTTPService]
takes it generically), so it is library code now. The struct is still there
for an app that needs after_response or tick, and it is three lines.
Every view got mut state. With one uniform view type, a view that only
reads still received the state mutably, which throws away something Mojo can
check and Python cannot even state. Two tables cost a Bool and an Int32
per route and buy a compiler-enforced answer to "does this view write?" —
a question this framework has a specific reason to ask, shared mutable
state across serving threads being a measured 0.7x cliff. poe sabotage-views is the gate, because the claim is about what does not
compile and no passing test can show that.
The table did not say where a view runs. /health was answered inside
func, so it became a pool job and paid an offload round trip;
WSGIHandler answers its own health path in before_request precisely to
avoid that. add_loop registers a view the loop answers directly. Loop
views receive no state, and that is the interesting part: under
--blocking-threads each pool thread owns a whole handler built on the
thread that will use it, and the loop has its own, so a loop view reading
app state would read a different copy than every other view — the same URL
answering differently depending on where it ran. Giving it nothing to read
makes that unrepresentable. Loop routes live in a second Router because
before_request runs for every request, and matching them against the full
table would route twice on every request that is not one.
Views are stored as thin function pointers
The load-bearing language fact, and it took a probe to find: fn is gone in
Mojo 1.0 and a function type spells def (...) -> ..., but that type is
an existential and is not Movable, so it cannot go in a List. The
compiler's own error message names the concrete type — raises thin — and
a thin function type is Movable and stores fine:
comptime View = def (
HTTPRequest, List[String], mut Self.S
) raises thin -> HTTPResponse
It works as a struct-level comptime referencing Self.S, so Views[S] is
generic over each app's state type without the spelling appearing four
times.
Each difference from the Python is paying for something
State is a parameter, not a global. Django's views reach Note.objects
— a module-level global holding a connection pool. Mojo has no global var
(src/global_slot.mojo reaches pop.global_alloc for the two cases that
genuinely need static storage, and fork copies those rather than sharing
them). This framework could not use one anyway: under --threads a
WSGIApp, an SSERegistry, a ProvisionPool and an m0-sqlite
Connection are per-thread by rule. So the state a view needs is the
caller's, handed in. The third argument is that rule made visible rather
than a workaround for the missing feature.
Captured parameters arrive as a list, not as named arguments. Django
passes URL captures as extra positional arguments and can, because its
resolver calls a dynamically-typed callable. A List[View] must hold one
uniform type and Mojo cannot vary arity across it.
Wrapping the request and its captures in a single struct was the obvious
alternative, and it is the one dead end here: the wrapper wants a
Pointer[HTTPRequest, o], which puts an origin parameter on the struct,
which lands in the stored function type and stops the table being one list.
A wrapper that owned the request instead would copy it per dispatch —
exactly the allocation Router.match is written to avoid, whose docstring
brags that a 404 allocates nothing at all. A plain borrowed HTTPRequest
argument costs neither: it names no origin and copies nothing. So the
captures ride beside the request rather than inside it.
This is the design's residual weakness and it is worth naming plainly.
params[0] is an index, and an index drifts against its pattern the same
way a handler id drifted against its dispatch arm: insert a segment before
an existing capture and every view on that route shifts by one, silently.
The router already carries the names — add stores :name minus the
colon and comments that matching never reads it — so this is not a data
problem but a type one. A RouteParams that borrowed them needs an origin
as a struct parameter, and none of ImmutableAnyOrigin, Origin[False],
Origin[False]._mlir_type or type_of(MutUntrackedOrigin) resolves on the
pinned toolchain. One that owned them allocates a String per name per
request. Passing names as a fourth borrowed argument is free but puts two
adjacent List[String] arguments in every signature.
So positions stay, and the mitigation is a convention: a view with more than one capture names them on its first lines. If a later toolchain makes origins spellable as struct parameters, this is the first thing to revisit.
There is no TemplateResponse, and the replacement is stricter. Its
purpose is to keep the template name and context inspectable instead of
rendering eagerly, so tests and middleware can look at or change them. The
mechanism is dict[str, Any], which Mojo does not have. What ports is the
purpose:
- a context is a struct — the data a page needs, with no HTTP in it
- a template is a function from that struct to a
String - the
state -> contextstep is a pure method returningOptional
A missing context field is then a compile error rather than a template variable that renders empty, and every part a test would want to assert on is reachable by calling a function with no request in hand. That is the repo's own stated design principle — functional core, I/O at the edges — arriving at the same place the article does from the other direction.
What is genuinely given up is late binding. Middleware cannot rewrite a response's context on the way out, because by then it is bytes. Nothing in this framework wanted to.
Guards are early returns, because a closure is not thin. A Python
decorator wraps a view and puts the wrapper back where the view was. Mojo
can express the wrapping — as a capturing closure — but a capturing closure
is not Movable, so the result cannot go back in the table. Guards are
therefore calls at the top of the body:
var denied = require_key(req, store)
if denied:
return denied.take()
require_key returns Optional[HTTPResponse] — the rejection, or nothing.
Returning the reply rather than a permission Bool means a guard cannot be
misread as the inverse of itself, and it keeps this framework's habit of
answering with responses rather than raising across the handler boundary on
a hot path. The article prefers explicit, traceable logic over decorator
magic anyway; here the language does not offer the alternative, which is a
convergence rather than a compromise.
404 is a view. set_not_found takes one, so an app that wants a styled
404 page writes a page. The default stays RFC 9457 problem+json because most
of this framework's callers are not browsers.
What is not claimed
Nothing here is measured. The dispatch was an if chain over a handful of
integer comparisons and is now one indirect call through a List, plus a
Bool and an Int32 load to pick the table; that is very likely a wash and
was not benchmarked, because the change is about whether a URL's answer is
findable and not about throughput. add_loop saves an offload round trip
per hit and that is not measured either.
Views is gated by test_views.mojo and the read/write split by poe sabotage-views. The two converted apps are gated on the wire by
smoke-notes and smoke-fragment-notes, which passed unchanged across
the conversion — that, and not a diff of the sources, is what "the wire
output did not move" rests on.
ViewService is not proven under --blocking-threads: that path wants a
PoolHandler conformance with a make(ctx) of its own, which this does
not have yet. An app using the pool writes its own handler struct today.