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.
- name
axum-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.
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,ExtensionimplementFromRequestPartsand may appear in any order before it.- Default rejections are plain text. For JSON errors, wrap with
axum_extra’sWithRejection<Json<T>, AppError>or write a custom extractor implementingFromRequestthat mapsJsonRejectionintoAppError. - 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-Sendfuture.
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 chainedRouter::layercalls the last call is outermost. Mixing both styles is the usual source of “my request ID is missing from logs”. TimeoutLayer::newis deprecated since tower-http 0.6.7; usewith_status_code.- Axum’s
Jsonalready 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_statethat validates the token, thenreq.extensions_mut().insert(CurrentUser { .. }), returning401on 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. Runcargo sqlx prepare --checkin 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::Mutexguard across.awaitmakes the future!Sendand the handler fails to compile; scope the guard or usetokio::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;CatchPanicLayerhides 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.