A lot of teams are sitting on the same situation: a comfortable monolith (Rails, Django, Node, Laravel) with a healthy Postgres database, and one or two endpoints that have grown hot enough to hurt. Rewriting the whole app is overkill. But carving out a single endpoint into a small Rust service that talks to the same database? That’s a high-leverage, low-risk move.
This post is written for someone who can write Python or Ruby or TypeScript comfortably and has heard of Rust but never shipped it. We’ll walk through three passes (beginner, intermediate, and advanced), and I’ll explain every line of code as we go. By the end you should be able to read an Axum codebase without flinching.
I’m going to assume you have Rust installed. If not, one command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
That installs rustup (the Rust toolchain manager), rustc (the compiler), and cargo (the package manager / build tool). Think of cargo as npm, pip, and make rolled into one.
Why Axum?
Axum is a web framework built by the Tokio team. Three things matter here:
- It sits on top of
tokioandhyper. Tokio is Rust’s async runtime, the equivalent of Node’s event loop. Hyper is a battle-tested HTTP implementation. Axum just gives you ergonomic glue on top. - It uses
towerfor middleware. Tower is a generic “service” abstraction. Anything written for Tower (timeouts, retries, load shedding, tracing, rate limiting) works in Axum without modification. - Handlers are just async functions. No macros decorating routes, no trait you implement, no inheritance. You write
async fn, you ask for what you need via function arguments, you return something serializable. That’s the whole API.
The “ask for what you need via function arguments” part is the magic, and we’ll come back to it. It’s called the extractor pattern.
Beginner: Your first Axum service, line by line
Let’s create a brand new project:
cargo new orders-service
cd orders-service
cargo new creates a directory with this layout:
orders-service/
├── Cargo.toml # like package.json — dependencies + metadata
├── Cargo.lock # like package-lock.json — exact resolved versions
└── src/
└── main.rs # the entry point of the binary
Open Cargo.toml and replace the [dependencies] section:
[package]
name = "orders-service"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7" # the web framework
tokio = { version = "1", features = ["full"] } # async runtime
serde = { version = "1", features = ["derive"] } # serialize/deserialize
serde_json = "1" # JSON specifically
A few things worth pausing on:
features = ["full"]on tokio. Cargo features are opt-in pieces of a crate.tokio’s “full” feature pulls in the multi-threaded runtime, signal handling, timers, the works. For a real service this is fine; for a tiny library you’d pick narrower features.features = ["derive"]on serde. This enables the#[derive(Serialize)]macro we’ll use in a moment. Without it,serdeis just trait definitions and you’d have to hand-write the serialization.- No
[bin]section needed.cargo newdefaulted to a binary crate, sosrc/main.rsis automatically the entry point.
Now the actual service. Open src/main.rs and replace its contents:
// Bring the bits of Axum we'll use into scope.
// `Router` lets us declare routes; `routing::get` is the helper for GET handlers.
// `Json` is both an extractor (to read JSON bodies) and a response wrapper (to send them).
use axum::{routing::get, Json, Router};
use serde::Serialize;
// `Serialize` is a Serde trait. The derive macro generates the boilerplate
// that turns this struct into JSON: {"status": "ok"}.
#[derive(Serialize)]
struct Health {
status: &'static str,
}
// `#[tokio::main]` is a macro that transforms our async `main` into a regular
// sync `main` that starts the Tokio runtime and blocks on the async body.
// Without it, you'd have to write the runtime setup by hand.
#[tokio::main]
async fn main() {
// Build the router. This is just a value — nothing has started yet.
let app = Router::new()
// When a GET request comes in for `/`, run the closure.
// Returning a plain string makes Axum send a 200 with text/plain.
.route("/", get(|| async { "orders-service" }))
// When a GET request comes in for `/health`, run the `health` function.
.route("/health", get(health));
// Bind a TCP listener on port 3000, on all interfaces (0.0.0.0).
// `.await` yields back to the runtime until the bind completes.
// `.unwrap()` panics if it fails — fine for `main` while learning;
// we'll replace it with proper error handling shortly.
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
// Hand the listener and router to Axum's server loop.
// This call only returns when the server stops.
axum::serve(listener, app).await.unwrap();
}
// A handler. Every Axum handler is an `async fn` (or async closure).
// The return type tells Axum how to build the HTTP response:
// `Json<Health>` -> serialize `Health` to JSON, set Content-Type, return 200.
async fn health() -> Json<Health> {
Json(Health { status: "ok" })
}
Run it:
cargo run
The first build will take a minute: Rust compiles every dependency from source. Subsequent builds are fast because of incremental compilation. In another terminal:
curl -i http://localhost:3000/health
# HTTP/1.1 200 OK
# content-type: application/json
# content-length: 15
#
# {"status":"ok"}
Let’s break down what just happened
The flow on a request:
- The OS hands a TCP connection to
hyper(via theTcpListener). hyperparses the HTTP request and hands anhttp::Requestto Axum.- Axum’s router looks at the method + path, finds your handler, and calls it.
- Your handler returns something that implements
IntoResponse.Json<T>implements it by serializingTand settingContent-Type: application/json. - Hyper writes the response back to the socket.
Every layer here is async. While your handler is awaiting the database, the same thread can be serving other requests. This is the entire reason an Axum service can hold tens of thousands of concurrent connections on modest hardware. There’s no thread-per-request cost.
Adding a route with path parameters
Let’s add an endpoint that echoes back a customer ID. This introduces our first extractor:
use axum::extract::Path;
async fn get_customer(Path(id): Path<u64>) -> String {
format!("you asked for customer {id}")
}
// In the router:
.route("/customers/:id", get(get_customer))
What’s happening here:
Path<u64>is an extractor. Axum sees this argument type and knows: “go look at the path parameters, find one I can parse asu64, and pass it in.”Path(id)is destructuring.Pathis a tuple struct, soPath(id)pulls the inner value out intoid. Equivalent tolet id = path.0;.- The handler returns
String, which Axum sends astext/plain. - If the user hits
/customers/abc, the parse fails and Axum automatically returns a 400 Bad Request. You didn’t write that error handling; the extractor did.
Test it:
curl http://localhost:3000/customers/42
# you asked for customer 42
curl -i http://localhost:3000/customers/abc
# HTTP/1.1 400 Bad Request
# ...
Accepting a JSON body
A POST handler that reads JSON and writes JSON:
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateCustomer {
name: String,
email: String,
}
#[derive(Serialize)]
struct Customer {
id: u64,
name: String,
email: String,
}
// `Json<T>` as an *argument* means: parse the request body as JSON into T.
// `Json<T>` as a *return* means: serialize T to JSON.
// Same type, two directions — Axum picks behavior based on position.
async fn create_customer(Json(payload): Json<CreateCustomer>) -> Json<Customer> {
Json(Customer {
id: 1, // pretend we wrote to a DB
name: payload.name,
email: payload.email,
})
}
// In the router:
use axum::routing::post;
.route("/customers", post(create_customer))
The thing to notice: argument order doesn’t matter, types do. You can put your extractors in any order. Axum looks at the type of each argument and runs the matching extractor. This is the entire framework’s mental model, and it scales from one extractor to ten.
Beginner best practices
- Pin your versions. Axum’s API still evolves between minor versions (0.6 → 0.7 was a breaking change). Pin in
Cargo.tomland read release notes deliberately. - Return typed responses, not strings. Even
/healthshould return a struct. It forces you to think about response shape, makes the OpenAPI generators happy later, and gives clients something stable to parse. - Keep
#[tokio::main]inmain.rsonly. Anything you might one day want to test or reuse from another binary should be runtime-agnostic. Library code shouldn’t decide the runtime. - Use
cargo check, notcargo build, during development. It runs the type-checker without producing a binary, and it’s 3–5x faster.
Intermediate: Talking to an existing project’s database
Now the real microservice. Imagine your Rails app owns this Postgres table (managed by ActiveRecord migrations):
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL,
total_cents BIGINT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
You want a GET /orders/:id and GET /orders?customer_id=... in Rust, faster and with predictable latency. The rule: the Rust service is a guest in someone else’s house. It does not migrate, does not rename columns, does not invent constraints. It reads, and writes only what it owns.
Step 1: Add real dependencies
Update Cargo.toml:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio", # use Tokio (not async-std) as the runtime
"postgres", # the Postgres driver
"macros", # query! / query_as! compile-time-checked macros
"uuid", # UUID column type support
"chrono", # DateTime column type support
] }
uuid = { version = "1", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }
# Observability / middleware
tower-http = { version = "0.5", features = ["trace", "timeout", "cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
anyhow = "1" # convenient `anyhow::Error` for "I don't care, just propagate it"
thiserror = "1" # ergonomic custom error types (we'll use this in advanced)
Worth understanding:
tracingvslog.logis the older, simpler crate (one log line at a time).tracingadds spans: a way to group log lines under a request, with structured fields. For a microservice,tracingis the right default.anyhowvsthiserror.anyhow::Erroris for application code: “wrap any error, give me a backtrace, I’ll handle it at the top.”thiserroris for library code: it generates well-defined error enums. We’ll use both.
Step 2: A connection pool, shared via state
use axum::{
extract::{Path, Query, State},
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::{postgres::PgPoolOptions, PgPool};
use std::time::Duration;
use uuid::Uuid;
// AppState is the bundle of things every handler can borrow.
// We use `#[derive(Clone)]` because Axum clones the state into each request.
// PgPool is internally an Arc, so cloning is cheap — it just bumps a refcount.
#[derive(Clone)]
struct AppState {
db: PgPool,
}
Important mental model: PgPool is a handle to a pool, not the pool itself. Cloning it doesn’t open new connections; it shares the same pool. Inside, it’s Arc<PoolInner>, and Arc is Rust’s atomically-reference-counted smart pointer. Same idea as shared_ptr in C++ or just object references in Python, but explicit.
Step 3: Wire up main
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing. `EnvFilter::from_default_env()` reads the `RUST_LOG`
// env var, so you can do: `RUST_LOG=info,sqlx=warn cargo run`
// to set the default level to info but quiet down sqlx's chatty query logs.
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,sqlx=warn".into()),
)
.init();
// Read DATABASE_URL from the environment.
// The `?` operator on a `Result` either unwraps it (success) or returns
// the error from the enclosing function. It's the workhorse of Rust error handling.
let database_url = std::env::var("DATABASE_URL")?;
// Build the pool. These numbers matter — see "Sizing the pool" below.
let db = PgPoolOptions::new()
.max_connections(20) // hard upper limit
.min_connections(2) // keep some warm so cold-start is fast
.acquire_timeout(Duration::from_secs(3)) // fail fast if pool is exhausted
.idle_timeout(Duration::from_secs(600)) // close idle conns after 10 min
.connect(&database_url)
.await?;
// Health-check the DB once at startup. Crash early if it's misconfigured.
sqlx::query("SELECT 1").execute(&db).await?;
tracing::info!("connected to postgres");
let state = AppState { db };
// The router. `.with_state(state)` attaches our `AppState` so handlers
// can ask for it via `State<AppState>`.
let app = Router::new()
.route("/health", get(health))
.route("/orders/:id", get(get_order))
.route("/orders", get(list_orders))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
tracing::info!("listening on {}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(()) // `Ok(())` is the unit success value; matches our `anyhow::Result<()>` return.
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
A few new things explained:
anyhow::Result<()>return onmain. This lets us use?for error propagation right insidemain. If anything fails, Tokio prints the error with a backtrace and exits non-zero.serde_json::json!macro. A convenient way to build a JSON value without defining a struct. Use sparingly (typed structs are still better), but it’s fine for one-off responses.tracing::info!. Structured logging. In production you’d swap the formatter to JSON; in dev the default pretty format is more readable.
Step 4: The actual handlers
// `sqlx::FromRow` lets sqlx map a row into this struct by column name.
// `Serialize` lets axum send it as JSON.
#[derive(Serialize, sqlx::FromRow)]
struct Order {
id: Uuid,
customer_id: Uuid,
total_cents: i64,
status: String,
created_at: chrono::DateTime<chrono::Utc>,
}
// GET /orders/:id
async fn get_order(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Order>, AppError> {
// `query_as` returns rows as the type parameter (here, Order).
// The `$1` is a parameter placeholder — sqlx sends it as a prepared statement,
// so SQL injection is impossible. Don't string-format SQL. Ever.
let order = sqlx::query_as::<_, Order>(
"SELECT id, customer_id, total_cents, status, created_at
FROM orders
WHERE id = $1",
)
.bind(id)
.fetch_one(&state.db)
.await?; // `?` propagates a sqlx::Error if the query fails
Ok(Json(order))
}
// GET /orders?customer_id=...&limit=...
#[derive(Deserialize)]
struct ListOrdersQuery {
customer_id: Uuid,
// `Option<T>` means "may or may not be present in the querystring".
// Combined with `#[serde(default)]` on a different field, you can have defaults.
limit: Option<i64>,
}
async fn list_orders(
State(state): State<AppState>,
Query(params): Query<ListOrdersQuery>,
) -> Result<Json<Vec<Order>>, AppError> {
let limit = params.limit.unwrap_or(50).clamp(1, 200);
let orders = sqlx::query_as::<_, Order>(
"SELECT id, customer_id, total_cents, status, created_at
FROM orders
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT $2",
)
.bind(params.customer_id)
.bind(limit)
.fetch_all(&state.db)
.await?;
Ok(Json(orders))
}
The two extractors in list_orders, State and Query, are the standard pair. Path is for /orders/:id-style parameters; Query is for ?customer_id=.... Both deserialize into structs you define.
Step 5: A real error type
That ? operator at the end of fetch_one requires a way to turn a sqlx::Error into our handler’s error type. Let’s build one with thiserror:
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use thiserror::Error;
// `Error` derive auto-implements std::error::Error.
// `#[error("...")]` provides the Display message for each variant.
#[derive(Debug, Error)]
enum AppError {
#[error("not found")]
NotFound,
// `#[from]` auto-generates `impl From<sqlx::Error> for AppError`.
// That's what makes `?` work seamlessly on sqlx calls.
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
}
// `IntoResponse` is the trait that turns any type into an HTTP response.
// By implementing it on AppError, we can return `Result<T, AppError>` from
// handlers and Axum knows how to render errors.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
// Special-case sqlx's "no row found" into a 404.
AppError::Database(sqlx::Error::RowNotFound) => {
(StatusCode::NOT_FOUND, "not found".to_string())
}
// Everything else is a 500. Log the *real* error server-side,
// but only send a generic message to the client — don't leak
// SQL or stack traces over the wire.
AppError::Database(err) => {
tracing::error!(error = ?err, "database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
// Build a JSON error body. Consistent shape makes client code easier.
let body = Json(serde_json::json!({ "error": message }));
(status, body).into_response()
}
}
Notice the rule: never leak internal errors to clients. Log the full thing, return a generic message. Attackers love verbose error pages.
Intermediate best practices
- Treat the schema as read-only by default. The Rust service should own zero migrations. The monolith is the source of truth; if you must write, write only to columns the monolith already understands and validates.
- Use
query_as!(with!) once you can. Without the bang, sqlx checks types at runtime. With it, sqlx checks them at compile time by talking to your dev database. It’s life-changing once set up, but it requiressqlx-cliand a.sqlx/offline cache for CI. - Don’t
SELECT *. Name the columns. The compiler can’t catch a column rename if it doesn’t know what columns you expected. - Size the pool deliberately.
max_connections× number-of-service-instances should comfortably fit inside Postgres’max_connectionsminus the monolith’s pool. A common mistake: spinning up 4 Rust pods × 50 connections each, while Rails is already holding 200, and Postgres’ ceiling is 300. Boom. - Add
TraceLayerfrom day one (we’ll wire it up next). Structured request logs cost almost nothing and pay for themselves the first time a customer says “it was slow at 3pm.”
Advanced: Patterns that hold up in production
Once the service is real, the failure modes change. The questions move from “does it work?” to “what does it do at 3am when the database is slow, the monolith is mid-deploy, and a misconfigured client is hammering you?”
1. The middleware stack
This is where tower earns its keep. Update the router:
use tower_http::{
cors::CorsLayer,
timeout::TimeoutLayer,
trace::TraceLayer,
};
let app = Router::new()
.route("/health", get(health))
.route("/ready", get(readiness))
.route("/orders/:id", get(get_order))
.route("/orders", get(list_orders))
.with_state(state)
// Layers apply bottom-up: the *last* `.layer()` runs *first* on the request.
// Trace innermost so it captures the real latency including downstream layers.
.layer(TimeoutLayer::new(Duration::from_secs(5)))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());
What each layer does:
TimeoutLayer: if a handler takes longer than 5 seconds, the request is aborted and the client gets a 408. Prevents a single slow query from holding a connection forever.TraceLayer: generates a span for every request with method, path, status, and latency. Pipe this to your log aggregator and you get free per-request observability.CorsLayer::permissive(): allows any origin. Fine for an internal service or during dev; for public APIs you’d configure specific origins.
2. Health vs. readiness
These are different things, and Kubernetes (or any orchestrator) needs both:
// Liveness: "is the process alive?" If this fails, restart me.
// Should not check downstreams — a flaky DB shouldn't cause a restart loop.
async fn health() -> &'static str {
"ok"
}
// Readiness: "can I serve traffic right now?" If this fails, take me out
// of the load balancer pool, but don't restart.
async fn readiness(State(state): State<AppState>) -> Result<&'static str, AppError> {
// A cheap query that verifies we can reach the DB.
sqlx::query("SELECT 1").execute(&state.db).await?;
Ok("ready")
}
The distinction sounds pedantic until your DB has a 30-second blip and Kubernetes restarts every pod in a hot loop. With separate endpoints, the pods get removed from the load balancer briefly and reattach when the DB recovers: no restarts, no cold caches, no cascading failure.
3. Graceful shutdown
When you deploy, SIGTERM hits the process. By default, in-flight requests die. With graceful shutdown, they finish, the pool drains, and clients never see a half-completed response:
use tokio::signal;
async fn shutdown_signal() {
// `ctrl_c` is Cmd+C in dev.
let ctrl_c = async {
signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
};
// SIGTERM is what container orchestrators send.
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
// `tokio::select!` waits for whichever future completes first.
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received, draining...");
}
// In main, replace the serve call:
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
tokio::select! is one of Tokio’s most useful macros. It races multiple futures and runs the arm of whichever completes first. Here we’re saying: “wait for Ctrl-C or SIGTERM, whichever comes first.”
4. Split read and write pools
Most “carve out one endpoint” services are read-heavy. Point reads at a Postgres read replica and keep a smaller write pool against the primary:
#[derive(Clone)]
struct AppState {
read: PgPool,
write: PgPool,
}
async fn main() -> anyhow::Result<()> {
// ... tracing setup ...
let read = PgPoolOptions::new()
.max_connections(40)
.connect(&std::env::var("DATABASE_READ_URL")?)
.await?;
let write = PgPoolOptions::new()
.max_connections(10)
.connect(&std::env::var("DATABASE_WRITE_URL")?)
.await?;
let state = AppState { read, write };
// ...
}
Two wins:
- Replicas absorb the read load without touching the primary.
- Blast radius is bounded. A runaway read query can’t exhaust the write pool, so writes still succeed even if reads are degraded.
5. Transactions, done right
When you do write, use a transaction. Even for a single statement: it makes the future-you who adds a second statement six months later not have to think:
async fn cancel_order(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Order>, AppError> {
// Begin a transaction. `tx` holds the connection until it's committed or rolled back.
let mut tx = state.write.begin().await?;
// Lock the row so concurrent cancellations can't race.
// `FOR UPDATE` blocks other writers until our transaction ends.
let order = sqlx::query_as::<_, Order>(
"SELECT id, customer_id, total_cents, status, created_at
FROM orders
WHERE id = $1
FOR UPDATE",
)
.bind(id)
.fetch_one(&mut *tx) // `&mut *tx` reborrows the transaction's connection
.await?;
if order.status == "shipped" {
// Drop the transaction explicitly — Rust would do it for us, but
// being explicit signals intent. (Drop without commit = rollback.)
return Err(AppError::Conflict("cannot cancel a shipped order".into()));
}
let updated = sqlx::query_as::<_, Order>(
"UPDATE orders
SET status = 'cancelled', updated_at = now()
WHERE id = $1
RETURNING id, customer_id, total_cents, status, created_at",
)
.bind(id)
.fetch_one(&mut *tx)
.await?;
// Commit. If this fails, the transaction is rolled back automatically.
tx.commit().await?;
Ok(Json(updated))
}
You’d add a Conflict(String) variant to AppError that returns a 409. The lock + check + update pattern is the standard way to avoid race conditions when two requests arrive for the same order at the same moment.
6. Observability metrics
Logs answer “what happened to this request?” Metrics answer “what’s happening to the fleet right now?” Add Prometheus metrics with axum-prometheus:
axum-prometheus = "0.7"
use axum_prometheus::PrometheusMetricLayer;
let (prometheus_layer, metric_handle) = PrometheusMetricLayer::pair();
let app = Router::new()
.route("/orders/:id", get(get_order))
.route("/metrics", get(|| async move { metric_handle.render() }))
.with_state(state)
.layer(prometheus_layer);
Now /metrics exposes request count, latency histogram, and status code breakdown in Prometheus text format. Point your scraper at it and you have dashboards.
7. Test against a real Postgres
Mocks lie about your queries. sqlx::test spins up a fresh database per test:
// tests/orders.rs
use sqlx::PgPool;
#[sqlx::test(migrations = "./migrations")]
async fn get_order_returns_404_for_missing(pool: PgPool) {
// `pool` is injected by the macro; each test gets an isolated DB.
let app = my_crate::build_app(pool);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/orders/00000000-0000-0000-0000-000000000000")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404);
}
For this to work cleanly, refactor main so the router is built by a function you can call from tests:
pub fn build_app(db: PgPool) -> Router {
let state = AppState { db };
Router::new()
.route("/orders/:id", get(get_order))
// ... etc
.with_state(state)
}
This is also why we put as little as possible in main.rs: anything in main.rs can’t be reached from integration tests.
8. Graceful production project structure
By the time you’re shipping, the layout looks something like:
src/
├── main.rs # only: setup, signal handling, call lib::serve()
├── lib.rs # `pub use` re-exports; build_app(); shared types
├── error.rs # AppError + IntoResponse
├── state.rs # AppState
├── handlers/
│ ├── mod.rs
│ ├── orders.rs # get_order, list_orders, cancel_order
│ └── health.rs # health, readiness
└── db/
├── mod.rs
└── orders.rs # SQL queries, isolated from HTTP concerns
The split between handlers/ and db/ matters: handlers deal with HTTP (extractors, status codes, JSON), and db/ modules deal with SQL. A handler should look like:
async fn get_order(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Order>, AppError> {
let order = db::orders::find_by_id(&state.read, id).await?;
Ok(Json(order))
}
If a handler is more than ten lines, it’s usually because business logic leaked in. Push it down.
When not to reach for Axum
A short reality check, because Rust is not a free lunch:
- If a single slow SQL query is the bottleneck, a Rust rewrite won’t help. Postgres still runs the query at the same speed. Fix the query (indexes, plan, schema) before reaching for a new language.
- If the team has zero Rust experience, the ramp-up is months. A Node or Go service may be 80% as fast and 10x faster to ship. Be honest about that.
- If the endpoint is mostly orchestrating other services, any async runtime works. Rust shines on CPU-bound or concurrency-bound paths, not on “wait for three HTTP calls and stitch the results.”
Axum is the right call when you have a hot endpoint, a healthy database, and a need for predictable tail latency under load.
Where to go next
Future posts in this series will dig into:
- Wrapping a Rails-owned Postgres database without breaking ActiveRecord callbacks
- Streaming responses and Server-Sent Events with Axum
- Sharing authentication with the monolith (JWTs, signed cookies, shared sessions)
- Deploying a Rust microservice next to a Rails app on the same infrastructure
- Compile-time-checked SQL with
sqlx::query!and the offline workflow for CI
If you’re carving out your first Rust service from an existing app, the most important thing is to start small: one endpoint, one table, one clear win. Axum makes that first step shorter than you’d expect, and the second one even shorter.