axa
axa is a small shared library for web services built directly on Axum and sqlx.
It holds the plumbing every service needs and none of them should write twice: route tables, a standard middleware stack, health checks, error responses, configuration, logging, graceful shutdown, a command line, background jobs, and a Postgres job queue.
The specification is spec/index.md. It is the single source
of truth, and every requirement in it names the test that proves it.
Why it exists
A framework gives you a lot for free, and asks you to trust what it does on your behalf. For a service handling identity, clinical, or government records, that trade runs the wrong way: an auditor has to read the framework as well as your code, and each of its dependencies joins your supply chain.
axa takes the opposite position:
- You can read the whole path.
main.rsis ordinary Rust that calls ordinary functions. Nothing calls into your code from behind a trait. - One copy. A defect in shared plumbing is fixed once, and an auditor reads it once.
- Fewer dependencies. Replacing Loco and SeaORM with sqlx took one measured service from 510 normal dependencies to about 348, before counting anything added to replace them.
- Safe defaults that fail closed. A production service with no database URL, or with a wildcard CORS origin, refuses to start.
A library, not a framework
There are no lifecycle hooks, macros, or generated code. AppContext is a plain
struct, passed as ordinary Axum state, and AppRoutes builds an ordinary
axum::Router. Any piece can be read, replaced, or left out on its own.
Some things will deliberately never be added: route macros, a generic CRUD controller, a dependency-injection container, a plugin system, a configuration language, and a database abstraction. Each one puts indirection back between a reader and the behaviour.
Installing
Add it from crates.io:
[]
= "0.1"
| Feature | Adds |
|---|---|
queue |
The Postgres job queue |
testing |
Request-level test helpers, built on axum-test |
sea-orm |
A SeaORM-backed AppContext, migration helpers, and model errors. Transitional: it goes once the last service has moved to sqlx |
sqlx |
axa-sqlx: pooling, migrations, and test resets |
pagination |
axa-pagination: offset pages, count headers, and LIKE escaping |
problem |
axa-problem: RFC 9457 problem details. No service uses this yet |
prometheus |
axa-prometheus: Prometheus text exposition |
versioning |
axa-versioning: Accepts-version negotiation |
Quick start
This is a complete service with one route:
use ;
async
async
It listens on port 1241 and serves /api/hello, /_ping, /_health, and
/_readiness under the standard middleware. It stops cleanly on SIGTERM or
Ctrl-C, letting requests in flight finish.
The default port spells the name: a=1, x=24, a=1.
What it provides
Routes
Routes is a group sharing a prefix, and AppRoutes collects the groups:
use *;
async
async
async
A "/" route is served at the prefix with no trailing slash, and two methods on
one path merge. The fields are public, so a test can check what is mounted
without booting anything:
# use *;
# async
let group = new.prefix.add;
assert!;
Middleware
AppRoutes::into_app applies this stack, or call middleware::apply on any
router. From the outside in:
| Layer | Behaviour |
|---|---|
| Request id | Keeps a caller's x-request-id after removing unsafe characters, or mints a UUID. Always returned |
| Tracing | Logs each request |
| Panic catching | Turns a handler panic into a 500 |
| Security headers | x-content-type-options, x-frame-options, referrer-policy, and optionally HSTS. A handler's own value wins |
| CORS | Off unless origins are configured |
| Timeout | Answers 408 after REQUEST_TIMEOUT_SECONDS |
| Compression | Gzip, Brotli, and Zstd when enabled |
| Body limit | Answers 413 over BODY_LIMIT_BYTES |
Errors
Handlers return Result<T> using axa::Error, which maps to a status and a JSON
body:
| Error | Status | Body |
|---|---|---|
Error::NotFound |
404 |
Fixed text |
Error::Unauthorized(_) |
401 |
Fixed text; yours is only logged |
Error::BadRequest(_) |
400 |
Your text |
Error::CustomError(status, detail) |
Yours | Yours |
| Anything else | 500 |
Fixed text; the real error is only logged |
So a failed database call cannot put a hostname or a query in a client's
response. The not_found, unauthorized, and bad_request functions build
these for you.
The body is {"error": ..., "description": ...} today, and is moving to RFC 9457
application/problem+json.
Configuration
There are no config files. A service states its defaults in code, and the environment overrides them, so what a deployment runs with is visible in one place.
| Variable | Effect |
|---|---|
APP_ENV |
development, test, or production. LOCO_ENV is also read. Any other name gets production's stricter behaviour |
PORT, BINDING, HOST |
Listening port, address, and public URL |
DATABASE_URL |
Connection string. Required in production for a service with a database |
DB_MIN_CONNECTIONS, DB_MAX_CONNECTIONS |
Pool size |
DB_CONNECT_TIMEOUT, DB_IDLE_TIMEOUT |
Pool timeouts, in milliseconds |
DB_LOGGING, DB_AUTO_MIGRATE |
Statement logging, and migrating at boot |
LOG_ENABLE, LOG_LEVEL, LOG_FORMAT, LOG_FILTER |
Logging. RUST_LOG wins over all of them |
BODY_LIMIT_BYTES |
Largest accepted request body |
HTTP_COMPRESSION, HTTP_HSTS |
Response compression and HSTS |
REQUEST_TIMEOUT_SECONDS |
Per-request timeout. 0 turns it off |
CORS_ALLOWED_ORIGINS |
Comma-separated origins |
WORKER_MODE |
BackgroundQueue, BackgroundAsync, or ForegroundBlocking |
Defaults change with the environment:
| Setting | Development | Test | Production |
|---|---|---|---|
| Database | Local, derived from the service name | Local, derived from the service name | Required |
| Migrate at boot | Yes | Yes | No |
| Compression | Off | Off | On |
| Request timeout | Off | Off | 60 seconds |
| HSTS | Off | Off | On |
| CORS | Any origin | Any origin | None until listed; * is refused |
| Log format | Compact | Compact, off by default | JSON |
| Jobs | Queue | In the foreground | Queue |
Config::from_lookup takes the lookup as a function, so configuration can be
tested without touching the process environment:
use ;
let config = from_lookup
.unwrap;
assert_eq!;
Health checks
| Route | Meaning |
|---|---|
/_ping, /_health |
The process is up |
/_readiness |
The database and queue also answer. 503 if not |
Command line
axa::cli::Cli is a clap parser that a service matches on itself:
service start [--worker | --all] [--binding ADDR] [--port PORT]
service db migrate | down [STEPS] | reset | status | truncate
service task [NAME] [KEY:VALUE ...]
Tasks
A task is a named job an operator runs by hand:
use ;
;
let mut tasks = default;
tasks.register;
Background jobs
A worker is a type with serializable arguments:
use *;
use ;
;
ReportWorker::perform_later(&ctx, args) then runs according to WORKER_MODE:
| Mode | Behaviour |
|---|---|
BackgroundQueue |
Saved to Postgres for a worker process |
BackgroundAsync |
Spawned on the current runtime |
ForegroundBlocking |
Run before returning. Tests use this |
The job queue
With the queue feature, jobs live in one axa_queue table, created on connect.
It needs no broker: Postgres FOR UPDATE SKIP LOCKED lets each worker loop claim
the oldest ready job in one statement, so no two loops ever claim the same row.
A job moves from queued to running to completed or failed, keeping its
error text on the row. A job still running after stale_minutes is assumed
lost with its worker and is queued again.
Delivery is at least once, so a job's work must be safe to repeat. Queue arguments are stored in plain text, so never put a secret in them.
Testing
With the testing feature, a service implements TestApp to say how it boots
for tests. That is the one trait axa asks a service to implement, and it exists
only for tests.
use *;
async
Requirements
- Rust 1.97 or newer, on edition 2024
- PostgreSQL for the
queueandsea-ormfeatures
Development
AXA_TEST_DATABASE_URL=postgres://...
The last command runs the queue tests against a real Postgres. Those tests have caught a real race that nothing else did, so run them when you touch the queue.
Read AGENTS.md before changing the crate. A change in behaviour
carries a spec edit, a code edit, and a test edit together.
Relationship to Loco
To move a project across, follow Converting a project from Loco to axa.
axa was extracted from what the Main X Index services relied on in Loco (Apache-2.0), and some differences are deliberate:
| Loco | axa |
|---|---|
A Hooks trait drives your app |
Your main.rs calls plain functions |
| YAML config rendered through a template engine | The environment, with defaults in code |
| Permissive CORS when enabled | Listed origins only in production |
x-powered-by header |
None |
| No request timeout or security headers by default | Both on in production; headers everywhere |
The code was written afresh rather than copied, so this crate can keep its own licence terms.
License
See LICENSE.md.