Arcature
An opinionated full-stack Rust web framework. One package, batteries included.
Arcature integrates proven wheels -- Axum, Tower, Tokio, SeaORM, SQLx, Inertia.js, OpenDAL, lettre, tracing -- and owns what sits between them: the application lifecycle, the request pipeline order, the conventions, and a coherent vocabulary. The raw Axum, Tower, SeaORM and SQLx escape hatches stay available for when the framework's opinions run out.
Status: pre-release. main breaks without notice, and 0.x says so
deliberately. The 2026.x versions on crates.io are from an abandoned
predecessor repository, are yanked, and share nothing with this one but the
name -- see Versioning. Where a subsystem is
narrower than its name suggests, that is said in
the guide and summarised under
What is not built yet.
Install
[]
= { = "https://github.com/ArcatureLabs/Arcature" }
Pin a revision -- a branch reference will move under you. Once Arcature is
published, cargo add arcature will be the whole install.
Requirements: Rust 1.97.1 or newer (edition 2024). Anything that uses the database or the job queue needs one of PostgreSQL 17, MySQL 8 or SQLite -- picked at build time, one per build.
Quick start
use EngineResult;
use *;
async
async
.build() turns the ApplicationBuilder into an Application; run() lives
on the latter. run() returns EngineResult<()> -- engine failures (a bound
port, a database that will not connect) are a different kind of failure from a
handler's, and deliberately do not share an error type with Result<Response>.
To scaffold a whole Laravel-shaped project instead:
What Arcature is
One package. One release unit. One version. Features exist only to reduce compile surface, never to turn the framework into a self-assembly kit. The default feature set compiles the generated application with no extra flags.
#![forbid(unsafe_code)] applies to the whole crate.
Two decisions shape everything else:
No hidden registry. No inventory, no linkme, no TypeId map, no
thread-locals. All framework metadata is &'static const data emitted by
macros and named by code you wrote, so cargo expand and "go to definition"
are enough to find out what is wired up.
No npm package. Arcature publishes no JavaScript. Applications use the
official @inertiajs/* adapters, and everything the Rust side hands the
browser travels as generated .ts files in the application's own tree rather
than through a framework runtime behind a virtual module.
The request pipeline
Layer order is a contract, written down in src/application/pipeline.rs and
asserted by the test suite. .inertia() before .csrf() and .csrf() before
.inertia() produce the same pipeline. Outermost first:
1 DevProxy 7 CORS 13 Timeout 19 PageContracts
2 Proxy 8 RequestId 14 Maintenance 20 RedirectMapper
3 Health 9 AccessLog 15 RateLimit 21 user .layer()s
4 UagEndpoint 10 CatchPanic 16 Session 22 Router
5 Compression 11 ErrorMapping 17 CSRF 23 StaticFiles
6 SecurityHdrs 12 BodyLimit 18 Inertia
Stages 5 through 21 are off unless asked for, with one exception: stage 20 is
on unless refused, because redirect().route(..) silently doing nothing is a
worse default than one extension lookup per response. The reasoning for each
position is in the module documentation and in
ADR 0004.
Architecture
| Subsystem | Built on | Feature |
|---|---|---|
| HTTP routing | Axum 0.8, Tower 0.5, tower-http 0.6 | always on |
Async runtime, #[arcature::main] |
Tokio 1.53 | macros |
| The DSL and its runtime contracts | -- | dx |
| Native Inertia.js v3 (server half) | the protocol, implemented directly | inertia |
| Database | SeaORM 2.0 + SQLx 0.9 over one pool | database + one db-* |
| Auth, sessions, CSRF, policies | argon2, tower-sessions, secrecy | auth |
| Validation | validator 0.21 | validation |
| Cache | Redis/Valkey (redis 1.5) | cache |
| Storage | OpenDAL 0.58 | storage-fs, storage-s3 |
| lettre 0.11 (rustls) | mail |
|
| Jobs | Database-backed queue, one claim strategy per dialect | jobs |
| Events | in-process dispatch | events |
| Realtime | WebSocket + SSE over axum | realtime |
| Problem Details (RFC 9457), OpenAPI | -- | api |
| Observability | tracing, request ids, JSON logs, Prometheus text, W3C trace context | observe |
| Static pages and assets | tower-http fs |
pages |
The arc CLI and templates |
clap 4 | cli, templates |
Operator opt-ins stay off by default: otel (OpenTelemetry over OTLP),
api-docs (an interactive API reference is a map of the attack surface),
oauth, storage-s3, dev-proxy, uag, test-kit.
Database drivers are separate features so a SQLite user does not compile the
PostgreSQL protocol. database on its own brings the crates but no driver;
exactly one of db-postgres / db-sqlite / db-mysql belongs in a build.
default picks db-postgres.
Versioning
Arcature follows semantic versioning. Current version 0.1.0, readable as
arcature::FRAMEWORK_VERSION.
Being in 0.x shifts SemVer one field left, and Cargo agrees: the breaking
bump is the minor (0.1 -> 0.2), the compatible one is the patch. So
arcature = "0.1" takes patches and stops at 0.2, and no exact pin is
needed to stay safe. The public API is not frozen -- that is what 0.x is
for -- so read the changelog before a minor bump.
0.1.0 is the first release of this codebase and it restarts the numbering.
crates.io also serves arcature 2026.0.0 through 2026.2.1, published from
the predecessor repository this one replaces; those are yanked and are not an
earlier version of what is documented here. A yank only withdraws a version
from new resolution, so anything already pinned to 2026.x keeps building.
A tour
Routes
use *;
Named routes generate URLs through Routes::url_for("links.show", &["7"]),
which returns Err(Error::NotFound(..)) for a name that is not in the table.
Paths use Axum 0.8 syntax ({id}, not :id).
Requests with validation
use *;
pub async
You write the Deserialize derive yourself; #[arcature::request] adds the
Validate derive and the marker trait, and must come after the derives. The
attribute on each field is #[validate(...)] -- validator's, not a
framework-specific one. A failure is a 422 with an RFC 9457 problem document
carrying an errors extension.
Controllers
use QueryModel;
use *;
;
Every method must be pub, async, take no self, and declare a return
type; the macro rejects anything else with error[ARC-M004]. json takes one
argument -- the value -- and always answers 200. Use text(status, body)
when the status matters.
Db is not an Axum extractor. It comes out of your state, which is why the
example above reaches through State<AppState>.
Inertia pages
pub async
The inertia! macro requires a binding literally named inertia in scope.
The Client Exposure Firewall makes browser exposure opt-in: a type reaches the
browser only by being a ClientData, which #[page] and #[resource]
generate. Nesting a non-ClientData type inside one fails to compile.
Auth
use *;
pub async
pub async
login takes the user by reference and rotates the session id before binding
it -- session-fixation defence that is mandatory rather than opt-in. Auth<U>
is the extractor for the current user (OptionalAuth<U> when absent is fine);
loading the user from its id is your UserLoader impl, so the framework never
guesses how your users are stored.
Authorization is a separate step: auth.authorize::<Link, LinkPolicy>("update", &link)?.
Both type parameters are required.
Jobs
use Job;
use *;
use ;
// Enqueue.
jobs.enqueue.await?;
// Register the handler. `add` takes `&mut self`.
let mut registry = new;
registry.add?;
The derive is arcature::Job, imported explicitly: the prelude cannot glob it
in, because the derive and the Job trait share a name in the type namespace.
The queue runs over the pool the application already has -- no broker to run. Delivery is at-least-once, and each claim carries a UUID fencing token so a worker whose lease expired cannot complete a job another worker has since taken.
Claiming a job without two workers taking the same one is the part no dialect
does the same way, so src/jobs/dialect/ has one module each. PostgreSQL
claims with UPDATE .. RETURNING over FOR UPDATE SKIP LOCKED; MySQL 8 has
SKIP LOCKED but no RETURNING, so it picks then marks; SQLite has neither
and serialises on BEGIN IMMEDIATE. The three are not pretending to be the
same implementation, and SQLite's is a single-writer design by construction --
fine for one process, not a fleet.
Events
use Event;
use *;
use ;
let dispatcher = new
.register;
dispatcher
.dispatch
.await?;
In-process and not durable. Events cross the listener boundary as
serde_json::Value rather than through a TypeId map, which is what keeps
the no-hidden-registry rule intact; the cost is a serialise/deserialise round
trip per listener.
Cache
Cache is a value you hold, not a namespace of static functions:
use Duration;
let users = cache
.remember
.await?;
A miss is not an error, but a backend failure is -- and it does not run the
loader. There is no silent fail-open. The loader's error type only has to be
Into<CacheError>.
Storage
disk is an instance method on a connected Storage, and every data-path
method takes a validated StoragePath:
use *;
let storage = connect.await?;
let path = new?;
storage.disk.put.await?;
let data = storage.disk.get.await?;
StoragePath::new rejects traversal, absolute paths and empty segments, so a
user-supplied filename cannot escape the disk root. disk(name) panics for a
name that was never registered -- a typo is a bug, not a runtime branch; use
try_disk when the name is genuinely dynamic.
Mail is also a value: a Mailer plus a From address.
use Message;
use ;
use *;
;
let mail = from_str?;
mail.to.send.await?;
Mail::send hands your Mailable an Email builder with From and To
already set. On the builder, only the body terminators -- plain, html,
alternative, plain_with_attachments and alternative_with_attachments --
return a Result; everything before them is infallible. SMTP credentials have a
Debug that prints the type name and no Display at all, so they cannot be
logged by accident.
The arc CLI
| Command | Does |
|---|---|
arc new <name> |
Scaffold an application (--stack, --db, --dest). |
arc serve |
Run the application (--bind, --port). |
arc migrate |
Run pending migrations. |
arc schedule |
Run the scheduler. |
arc make:<kind> <name> |
Generate one artifact. 16 kinds: controller, model, migration, request, resource, policy, service, job, event, listener, middleware, command, page, test, factory, seeder. |
arc key:generate |
Generate the session key. |
arc storage:link |
Link public/storage to the local disk. |
arc db:seed, db:fresh, db:reset |
Database lifecycle. |
arc queue work|drain|stats |
Drive the job queue. |
arc doctor |
Check the environment. |
arc version |
Print the version. |
arc dev, arc typegen and arc build parse and report that they are not
wired yet. They are declared rather than hidden so arc --help shows the real
surface and a typo suggests the right name.
Documentation
The guide lives in docs/ and builds with mdBook:
Chapters: getting started, routing, controllers, validation, Inertia, database, cache, storage, auth, jobs, events, mail, testing, deployment, upgrading.
The decisions that are surprising enough to need a written record are in
docs/decisions/, each one page, each stating the decision,
the context, and the cost paid:
- No npm package
- The CSRF cookie is
XSRF-TOKEN, not__Host-csrf - Exactly one TCP port, in development too
- Layer order is a written contract
- There is no hidden registry
What is not built yet
Nothing on this list. Every surface the guide documents is wired to something that reads it; where a surface is narrower than its name suggests, the narrowing is written into its own documentation rather than tracked here.
The nearest thing to an exception is AppConfig: port is consumed, and
name, url and env are carried and readable but read by no framework
code -- no framework surface builds an absolute URL yet, and env is barred
by design from gating behaviour. That is stated on the type, and in
the deployment chapter.
Contributing
See CONTRIBUTING.md for the build, test and lint gates, and SECURITY.md for reporting a vulnerability privately. Changes are recorded in CHANGELOG.md.
The gates in one line each:
License
Apache-2.0. See LICENSE.