axum-patterns

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.

axum Patterns

Production patterns for axum — async Rust web framework built on tokio + tower.

When to activate

  • Creating axum services from scratch
  • Adding custom extractors, tower middleware
  • Configuring shared typed state
  • Mapping errors to idiomatic HTTP responses
  • Integrating with SQLx / sea-orm / diesel-async

Recommended structure

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

Router + State

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.

Extractors (input)

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.

IntoResponse + AppError

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:

  • Never expose Debug of internal errors in responses (leaks info)
  • Use tracing::error! + expose only a generic code
  • #[from] on thiserror enables ? in queries

Tower middleware

use 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).

SQLx integration

#[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).

Testing with axum-test

#[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

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.

Related to

  • skill rust-patterns — Rust idioms, ownership, traits
  • skill rust-testing — unit/integration/property tests
  • skill api-design — REST conventions
  • skill interface-contracts — module contracts