m0serve Realtime from a synchronous Python app

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 -> context step is a pure method returning Optional

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.