Axum Rust Services skill for Claude Code

Thin handlers, typed state, errors that map to HTTP. Claude writes Axum 0.8 services with a typed AppState, one AppError enum that maps sqlx failures to 404 and 409, extractors in the order the compiler demands, and a tower-http stack with request IDs, tracing, timeouts and body limits in a deliberate order. Queries are checked at compile time with committed offline metadata, shutdown drains in-flight requests, and tests use oneshot with a fresh database.

  • nameaxum-rust-services
  • size287 lines · ≈2,750 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds Rust HTTP services with Axum 0.8 and Tokio - Router and the 0.8 path syntax, extractor ordering and custom rejections, typed shared state with FromRef, an AppError type that implements IntoResponse, tower and tower-http middleware ordering, timeouts and body limits, sqlx pools, compile-checked queries and migrations, graceful shutdown, and oneshot tests. Use when starting an Axum service, upgrading from Axum 0.7, decoding a handler trait-bound compile error, adding auth or tracing middleware, or wiring sqlx into handlers.

Try asking

  • Scaffold an Axum service with Postgres, auth middleware and graceful shutdown
  • My handler fails with 'does not implement Handler'. Explain and fix it
  • Upgrade this service from Axum 0.7 to 0.8 and fix the routes

Use it when

  • Starting or extending an HTTP service on Axum and Tokio
  • Handler trait-bound or Send errors you cannot decode
  • Adding auth, tracing, timeout or CORS middleware
  • Wiring sqlx pools, migrations and compile-checked queries

Skip it when

  • The problem is borrow checker or lifetime errors in general Rust — use rust-ownership
  • The service is in Go — use go-services

How to check the result

cargo clippy with -D warnings, cargo test and cargo sqlx prepare --check pass, and live checks return 413 for oversized bodies, 408 on timeout, and complete in-flight requests on SIGTERM.

The common mistake it prevents

Putting the Json extractor before State or Path, or holding a std Mutex guard across an await, then fighting an unreadable Handler trait error instead of adding debug_handler.

~/.claude/skills/axum-rust-services/SKILL.md

--- name: axum-rust-services description: Builds Rust HTTP services with Axum 0.8 and Tokio - Router and the 0.8 path syntax, extractor ordering and custom rejections, typed shared… license: MIT ---

Done well means: handlers are thin and return Result<_, AppError>, state is a typed Clone struct (never Extension for core dependencies), every request has a timeout and body limit, blocking work is off the async runtime, sqlx queries are checked at compile time with offline metadata committed, and the server drains on SIGTERM.

Workflow

- [ ] 1. Inventory versions and existing layout
- [ ] 2. Define AppState, AppError, and the router skeleton
- [ ] 3. Write handlers with correctly ordered extractors
- [ ] 4. Add the middleware stack in the right order
- [ ] 5. Wire sqlx: pool, queries, migrations, offline data
- [ ] 6. Add graceful shutdown and tests
- [ ] 7. Run clippy, tests, and sqlx prepare --check

1. Inventory

cargo tree -i axum | head -3          # axum version actually resolved
cargo tree -d | grep -E "^(http|tower|hyper) "   # duplicate major versions = trouble
grep -rn '"/:\|/\*' src/ | head       # pre-0.8 path syntax to migrate

Axum 0.8 (current stable line, 0.8.9) changes from 0.7: path parameters are /{id} and wildcards /{*rest} (the old /:id panics at startup), extractor traits use native async fn (drop #[async_trait]), and Option<T> as an extractor requires the inner type to implement OptionalFromRequestParts, so a malformed value is an error instead of None.

[dependencies]
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "timeout", "cors", "request-id", "limit", "catch-panic"] }
serde = { version = "1", features = ["derive"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "uuid", "chrono", "migrate"] }
thiserror = "2"
anyhow = "1"
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }

[dev-dependencies]
http-body-util = "0.1"

axum 0.8 pairs with tower-http 0.6; tower-http 0.7 exists, so check cargo tree -d after bumping it. sqlx 0.9 is out (needs Rust 1.94, dynamic SQL strings must be wrapped in AssertSqlSafe, runtime and TLS features are separate); upgrade deliberately, not as a side effect.

2. State, errors, router

use axum::{extract::FromRef, http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use std::sync::Arc;

#[derive(Clone, FromRef)]
pub struct AppState {
    pub db: sqlx::PgPool,          // already an Arc internally
    pub cfg: Arc<Config>,
}

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("not found")]
    NotFound,
    #[error("conflict: {0}")]
    Conflict(String),
    #[error("invalid input: {0}")]
    Invalid(String),
    #[error(transparent)]
    Db(#[from] sqlx::Error),
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, msg) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
            AppError::Invalid(m) => (StatusCode::UNPROCESSABLE_ENTITY, m.clone()),
            AppError::Db(sqlx::Error::RowNotFound) => (StatusCode::NOT_FOUND, "not found".into()),
            AppError::Db(sqlx::Error::Database(e)) if e.is_unique_violation() => {
                (StatusCode::CONFLICT, "already exists".into())
            }
            _ => {
                tracing::error!(error = ?self, "internal error");   // log detail, return generic
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
            }
        };
        (status, Json(json!({ "error": msg }))).into_response()
    }
}
pub fn app(state: AppState) -> Router {
    let api = Router::new()
        .route("/orders", get(list_orders).post(create_order))
        .route("/orders/{id}", get(get_order).delete(delete_order))
        .route_layer(middleware::from_fn_with_state(state.clone(), require_auth));

    Router::new()
        .route("/healthz", get(|| async { "ok" }))
        .nest("/api", api)
        .layer(/* ServiceBuilder stack from step 4 */)
        .with_state(state)
}

FromRef lets handlers extract State<PgPool> directly. route_layer runs auth only on matched routes, so unknown paths still return 404 rather than 401.

3. Handlers and extractors

#[derive(Deserialize)]
pub struct CreateOrder { sku: String, qty: i32 }

pub async fn create_order(
    State(db): State<PgPool>,
    Extension(user): Extension<CurrentUser>,   // inserted by the auth middleware
    Json(body): Json<CreateOrder>,             // body extractor LAST
) -> Result<(StatusCode, Json<Order>), AppError> {
    if !(1..=99).contains(&body.qty) {
        return Err(AppError::Invalid("qty must be 1..=99".into()));
    }
    let order = sqlx::query_as!(
        Order,
        "insert into orders (user_id, sku, qty) values ($1, $2, $3)
         returning id, user_id, sku, qty, created_at",
        user.id, body.sku, body.qty
    )
    .fetch_one(&db)
    .await?;
    Ok((StatusCode::CREATED, Json(order)))
}

Extractor rules:

  • At most one body-consuming extractor (Json, Form, String, Bytes, Request, Multipart), and it must be the last argument.
  • Path, Query, State, HeaderMap, Extension implement FromRequestParts and may appear in any order before it.
  • Default rejections are plain text. For JSON errors, wrap with axum_extra’s WithRejection<Json<T>, AppError> or write a custom extractor implementing FromRequest that maps JsonRejection into AppError.
  • Put #[axum::debug_handler] on a handler when the compiler says it “does not implement Handler”; the error then names the offending argument or the non-Send future.

4. Middleware order

use tower::ServiceBuilder;
use tower_http::{
    catch_panic::CatchPanicLayer, cors::CorsLayer, limit::RequestBodyLimitLayer,
    request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
    timeout::TimeoutLayer, trace::TraceLayer,
};

// Inline this in app() as `.layer(ServiceBuilder::new()...)`: naming the returned
// layer type in a helper fn signature is painful because Router::layer needs the
// wrapped service's Send + Clone bounds too.
ServiceBuilder::new()                           // runs top to bottom on the request
    .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
    .layer(TraceLayer::new_for_http())
    .layer(PropagateRequestIdLayer::x_request_id())
    .layer(CatchPanicLayer::new())
    .layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10)))
    .layer(RequestBodyLimitLayer::new(1024 * 1024))
    .layer(CorsLayer::new())                // configure origins explicitly
  • With ServiceBuilder, the first layer listed is outermost. With chained Router::layer calls the last call is outermost. Mixing both styles is the usual source of “my request ID is missing from logs”.
  • TimeoutLayer::new is deprecated since tower-http 0.6.7; use with_status_code.
  • Axum’s Json already enforces a 2 MB default (DefaultBodyLimit); set the limit explicitly so it is visible and matches the API’s needs.
  • Auth: middleware::from_fn_with_state that validates the token, then req.extensions_mut().insert(CurrentUser { .. }), returning 401 on failure.

5. sqlx

let db = PgPoolOptions::new()
    .max_connections(cfg.db_max_conns)          // instances x this <= Postgres max_connections
    .acquire_timeout(Duration::from_secs(3))    // fail fast instead of queueing forever
    .connect(&cfg.database_url)
    .await?;
sqlx::migrate!("./migrations").run(&db).await?;
cargo install sqlx-cli --no-default-features --features rustls,postgres
sqlx migrate add -r create_orders          # reversible up/down pair
sqlx migrate run
cargo sqlx prepare                          # writes .sqlx/ query metadata; commit it
SQLX_OFFLINE=true cargo build              # CI builds without a database
  • query!/query_as! check SQL against a real schema at compile time; .sqlx/ makes that work offline. Run cargo sqlx prepare --check in CI so stale metadata fails the build.
  • Transactions: let mut tx = db.begin().await?; ... .execute(&mut *tx).await?; tx.commit().await?; A dropped transaction rolls back.
  • Never build SQL by string formatting user input; use bind parameters or QueryBuilder.
  • Running migrations at startup is fine for a single instance; with many replicas, run them as a separate deploy step (sqlx takes an advisory lock, but a slow migration then blocks every booting pod).

6. Shutdown and tests

async fn shutdown_signal() {
    let ctrl_c = async { tokio::signal::ctrl_c().await.expect("ctrl-c handler") };
    #[cfg(unix)]
    let term = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("sigterm handler").recv().await;
    };
    #[cfg(not(unix))]
    let term = std::future::pending::<()>();
    tokio::select! { _ = ctrl_c => {}, _ = term => {} }
}

let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
axum::serve(listener, app(state)).with_graceful_shutdown(shutdown_signal()).await?;
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use tower::ServiceExt;

#[sqlx::test]                                    // fresh database per test, migrations applied
async fn get_missing_order_is_404(db: PgPool) {
    let app = app(AppState { db, cfg: Arc::new(Config::test()) });
    let res = app
        .oneshot(Request::get("/api/orders/00000000-0000-0000-0000-000000000000")
            .header("authorization", "Bearer test").body(Body::empty()).unwrap())
        .await.unwrap();
    assert_eq!(res.status(), StatusCode::NOT_FOUND);
    let body = res.into_body().collect().await.unwrap().to_bytes();
    assert!(std::str::from_utf8(&body).unwrap().contains("not found"));
}

Gotchas

  • Holding a std::sync::Mutex guard across .await makes the future !Send and the handler fails to compile; scope the guard or use tokio::sync::Mutex.
  • CPU-heavy or blocking work (argon2, image processing, sync file IO) inside a handler stalls a runtime worker; use tokio::task::spawn_blocking.
  • Extension<T> missing at runtime is a 500; State<T> missing is a compile error. Prefer state for anything the app cannot run without.
  • .with_state() must be called on the outermost router; nested routers inherit it only if their state type matches.
  • unwrap() in handlers turns bad input into panics; CatchPanicLayer hides the crash but not the bug.

Verification

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo sqlx prepare --check

Then run the binary, send a request with an oversized body (expect 413), one that sleeps past the timeout (expect 408), and SIGTERM during a slow request (it completes before exit).

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc