Rainier is a web framework with opinions, and opinions are only useful when they are written down. Not as rules — every one of these has an exception somewhere in the codebase, documented where it lives — but as the reasons behind the shape of things. When the framework does something that surprises you, it is usually one of these at work.
None of them is about speed. Rust is fast, and a framework written in it inherits that without trying. What a framework has to earn is the rest: that the application you wrote on day one is still the one you understand in year five, that it tells you when it is wrong, and that running it is as ordinary as writing it.
The compiler is the first reviewer
Every bug has a cheapest place to be caught, and it is almost always earlier than where it was found. A mistake caught by a type is cheaper than one caught by a test, which is cheaper than one caught in review, which is a great deal cheaper than one caught by a customer.
So Rainier spends its design budget moving mistakes left. Middleware is attached to a route as a value, not a string looked up in a map: misspell it and the build fails, rename it and every route that used it is a compile error, listed by file and line. A controller states what it needs in its parameters — Bound<Post>, Validated<StorePost>, AuthenticatedUser<User> — and receives it, or the request is answered before the body runs. A relationship loads for a whole slice of parents at once, because there is no per-model load to call; N+1 queries are not discouraged, they are unrepresentable.
None of this is novel. It is what a type system is for. The discipline is to keep asking, of every convenience, whether the compiler could have been the one to say no.
Fail the boot, not the request
A configuration mistake has two moments when it can surface: when the process starts, or when the first request that depends on it arrives. The first is a failed deploy with a message on the screen of the person deploying. The second is an incident.
Rainier chooses the first wherever it can. A setting the framework cannot honour stops the boot, and the message names the variable, its value, and what it accepts — the same for a misspelt QUEUE_DRIVER as for LOOKOUT_STORE=memcache. A driver whose Cargo feature was not compiled in fails at boot, naming the feature. The scheduler refuses to start in production when a task asks for an overlap lock and the cache is per-process, because a lock every machine can take is a lock that means nothing.
"Refuses to start" sounds hostile. It is the opposite: it is the framework declining to pretend.
Everything that fails is an event
Frameworks are full of places where an error is swallowed on purpose. A 404 becomes a page. A job's failed attempt becomes a retry. A cache that is down becomes a miss. Each of these is the right behaviour — and each is a failure that, by default, nobody hears about.
In Rainier, every one of them is reported. Twenty-seven kinds of failure, from a failed HTTP request to a discarded queue message to a listener that threw, are dispatched on the event bus as typed values with their own fields, and once more inside a catch-all ExceptionReported. Listening for them is ordinary event handling. Filtering them is explicit: you say which kinds you do not want, rather than discovering later which ones you never got.
The test harness sees them too. A feature test can assert that nothing was reported — and will fail, loudly, the day something is.
Declare once, derive the rest
Anything stated twice will eventually be stated two different ways. The second copy is where drift starts, and it is always the one somebody reads.
So Rainier derives what can be derived. A table is created from the entity that describes it. A migration that adds a column and an index computes its own rollback, in the right order; the hand-written down — the half of a migration that goes stale first — is not there to go stale. The OpenAPI document for an endpoint is generated from the same validation rules that enforce it, so the schema cannot claim a field is optional while the validator rejects its absence.
Templates follow from the same idea, from the other side. The view language cannot call a function or evaluate an expression, because a template that can compute is a template that ends up holding business logic, stated once in the controller and again, slightly differently, in the markup.
One binary, sized to its job
A deployment should be a thing you can hold in your head. Rainier applications ship as one statically linked binary that serves HTTP, works the queue, runs the schedule and migrates the database — four commands, one image, as many process types as you need.
Cargo features are the sizing mechanism: a driver that is not compiled in does not exist, and cargo rainier features reads the environment a build will run in and turns on exactly what it names. The work that does not fit a general-purpose worker — a transcode, an export, anything that wants its own memory limit — can run as its own Kubernetes Job, sized for it, by returning a spec from the job that needs one.
The aim is not minimalism for its own sake. It is that "what is running in production" has a short, exact answer.
Observe what you already emit
Instrumentation that the application has to remember to add is instrumentation that will be missing from the code path that broke. Rainier instruments itself: with the telemetry feature, every request, query, cache call, job, mail and event opens a span, and the standard OTEL_* variables send them to your collector.
Lookout, the in-app diagnostics dashboard, is built from those same spans rather than from hooks of its own. It reads them in-process and groups them by the request or job that produced them, so the view you get in development and the traces you get in production are two readings of one record. It is off unless you turn it on, hidden from everyone you have not let in, and built so that nothing on the request path waits for it.
Migrate, don't stay
Most applications that move to Rainier are moving from something — usually PHP, often a framework whose conventions Rainier deliberately shares. The cheapest migration is the one that never has a cut-over night.
The compat-php add-in exists for that: it reads and writes the old application's sessions, encrypted cookies, cache values, queued jobs and $2y$ password hashes, so the two can serve the same users side by side while routes move across one at a time. It is a bridge, and it is documented as one. Use it to migrate, not to stay.
Say what it doesn't do
A framework's documentation is a set of promises, and the ones it leaves out matter as much as the ones it makes. Rainier keeps a public list of what it does not do yet — no localization, no clustered WebSocket rooms, no polymorphic relations, no SAML, a Redis queue that can lose a job it accepted — and each limit is written down on the page where you would otherwise trip over it.
This is not modesty. It is the same principle as failing the boot: a stated limitation is a decision you can plan around, and an unstated one is an outage you have not had yet.
Start with the installation guide, or see the principles at work on the features pages.