axum production patterns — tower middleware, extractors, typed state, error handling with IntoResponse, SQLx integration, tracing, testing with axum-test. Use when building or reviewing axum services in Rust.
Production patterns for axum — async Rust web framework built on tokio + tower.
src/
├── main.rs # tokio::main + Router build
├── config.rs # AppConfig via figment ou envy
├── state.rs # AppState (shared, Clone)
├── error.rs # AppError + impl IntoResponse
├── routes/
│ ├── mod.rs
│ ├── users.rs
│ └── health.rs
├── middleware/
│ └── auth.rs
├── db/
│ ├── mod.rs
│ └── users.rs # queries
└── models/
└── user.rs # structs + sqlx::FromRow
use axum::{Router, routing::{get, post}};
use sqlx::PgPool;
#[derive(Clone)]
struct AppState {
db: PgPool,
config: Arc<AppConfig>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let db = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let state = AppState { db, config: Arc::new(AppConfig::load()?) };
let app = Router::new()
.route("/health", get(health::check))
.route("/users", post(users::create))
.route("/users/:id", get(users::read))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app).await?;
Ok(())
}
State: #[derive(Clone)] is mandatory — axum clones per request. Use Arc<T> for large immutable data.
use axum::{extract::{State, Path, Query, Json}, http::HeaderMap};
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination { page: u32, per_page: u32 }
async fn list_users(
State(state): State<AppState>,
Query(page): Query<Pagination>,
headers: HeaderMap,
) -> Result<Json<Vec<UserOut>>, AppError> {
let users = db::users::list(&state.db, page.page, page.per_page).await?;
Ok(Json(users))
}
Extractor order matters: State first, then Path, Query, and finally body extractors (Json, Form) last — they consume the body.
The idiomatic pattern is a unified enum that implements IntoResponse:
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
#[derive(thiserror::Error, Debug)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("unauthorized")]
Unauthorized,
#[error("validation: {0}")]
Validation(String),
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("internal error")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not_found"),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
AppError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"),
AppError::Database(e) => {
tracing::error!(error = ?e, "database error");
(StatusCode::INTERNAL_SERVER_ERROR, "database")
}
AppError::Internal(e) => {
tracing::error!(error = ?e, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
}
};
(status, Json(json!({"error": {"code": code, "message": self.to_string()}}))).into_response()
}
}
Rules:
Debug of internal errors in responses (leaks info)tracing::error! + expose only a generic code#[from] on thiserror enables ? in queriesuse axum::{middleware, routing::get};
use tower_http::{trace::TraceLayer, cors::CorsLayer, timeout::TimeoutLayer};
let app = Router::new()
.route("/users", get(list_users))
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(30)))
.layer(CorsLayer::permissive()),
);
Order: .layer() applies BOTTOM-UP on the request, TOP-DOWN on the response. Trace should be the outermost one to capture everything.
route_layer vs layer: route_layer only applies to routes defined BEFORE it (useful for auth on subgroups).
#[derive(sqlx::FromRow, serde::Serialize)]
struct User {
id: i64,
email: String,
created_at: chrono::DateTime<chrono::Utc>,
}
pub async fn find_by_id(pool: &PgPool, id: i64) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as!(
User,
"SELECT id, email, created_at FROM users WHERE id = $1",
id
)
.fetch_optional(pool)
.await
}
Rule: use the sqlx::query_as! macro for compile-time checking against the DB (via sqlx prepare).
#[tokio::test]
async fn test_create_user() {
let state = test_state().await;
let app = build_router(state);
let server = axum_test::TestServer::new(app).unwrap();
let response = server
.post("/users")
.json(&json!({"email": "[email protected]", "password": "secret12345"}))
.await;
response.assert_status(StatusCode::CREATED);
let body: UserOut = response.json();
assert_eq!(body.email, "[email protected]");
}
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,axum=debug".into()))
.with_target(false)
.json() // production
.init();
Combine with TraceLayer::new_for_http() for automatic per-request spans.
rust-patterns — Rust idioms, ownership, traitsrust-testing — unit/integration/property testsapi-design — REST conventionsinterface-contracts — module contracts