Axum Router extractors, State, errors, middleware
Route to handlers that declare their inputs; keep fallible layers behind HandleError.
A handler is an async function whose arguments say what the request must provide. Keep Tower errors from reaching hyper bare.
§I. Frame
Sunday's Tokio seat picked the runtime. Today the service surface sits on it: axum 0.8.9 routes HTTP to handlers with a macro-free Router, declares request parts as extractors, shares typed state, and leaves middleware to tower::Service.
Done bar: you can build a Router with path params and JSON, inject State, return a typed error that becomes a response, and hang one from_fn middleware that stamps a header — all covered by cargo test without binding a port.
Grounding for this lesson is vendor docs only (docs.rs axum 0.8.9). No tome PDF row named Axum; TRPL ch17 covers futures and the runtime you already ran on 10-11.
§II. Router and handlers
From the crate root: build routes with Router::new().route(path, method_router), then axum::serve(listener, app) on a Tokio TcpListener.
use axum::{routing::get, Router};
let app = Router::new()
.route("/", get(root))
.route("/foo", get(get_foo).post(post_foo));
async fn root() {}
async fn get_foo() {}
async fn post_foo() {}
A handler is an async function that takes zero or more extractors and returns something that implements IntoResponse. Application logic lives in handlers; the router only chooses which one runs.
In axum 0.8 path templates use {id} (not the older :id form). Match that in labs and in any code you copy from older blog posts.
§III. Extractors
extract: an extractor implements FromRequest or FromRequestParts. Handlers declare what they need; if extraction fails, axum rejects the request and never calls the handler.
use axum::extract::{Path, Query, Json};
use std::collections::HashMap;
async fn path(Path(user_id): Path<u32>) {}
async fn query(Query(params): Query<HashMap<String, String>>) {}
async fn json(Json(payload): Json<serde_json::Value>) {}
Path deserializes path parameters. Query deserializes the query string. Json buffers the body and deserializes with serde. Return values that implement IntoResponse ; a &'static str is 200 text/plain; Json(value) is application/json.
Order matters when several extractors consume the body: only one may take the body. Put FromRequestParts extractors (Path, Query, State, headers) before a body extractor like Json.
§IV. State
The docs list four common ways to share state. Prefer State + Router::with_state.
use axum::{extract::State, routing::get, Router};
use std::sync::Arc;
struct AppState { /* ... */ }
let shared = Arc::new(AppState { /* ... */ });
let app = Router::new()
.route("/", get(handler))
.with_state(shared);
async fn handler(State(state): State<Arc<AppState>>) {
let _ = state;
}
State is cloned per request. Wrap expensive handles in Arc, or #[derive(Clone)] when every field is already cheap to clone. Router<S> with S != () means the router still needs state of type S; .with_state(s) supplies it and typically yields Router<()>, the form serve accepts.
FromRef lets a handler take a substate (State<ApiState>) without seeing the whole AppState. Extensions and closure captures work; Extensions fail at runtime with 500 if missing, and closures are the most verbose. Prefer State when the type is known at compile time.
§V. Errors that are still responses
error_handling: axum sits on tower::Service. If an error reaches hyper, the connection dies with no HTTP response. Axum therefore requires services whose error type is Infallible ; errors that cannot happen at that edge.
A handler async fn handler() -> Result<String, StatusCode> does not produce a Tower error when it returns Err(StatusCode::NOT_FOUND). StatusCode implements IntoResponse, so that Err is still a response. The docs say it plainly: these are not considered errors in axum.
For ? in handlers, use an intermediate error type that implements IntoResponse (see the official error-handling and anyhow-error-response examples). Extractor failures also reject before your handler runs.
When you embed a fallible Service or apply middleware that can fail (timeouts), convert errors with HandleError / HandleErrorLayer so the error type becomes Infallible again:
use axum::{
error_handling::HandleErrorLayer,
routing::get,
BoxError, Router,
};
use std::time::Duration;
use tower::ServiceBuilder;
use axum::http::StatusCode;
let app = Router::new()
.route("/", get(|| async {}))
.layer(
ServiceBuilder::new()
.layer(HandleErrorLayer::new(handle_timeout_error))
.timeout(Duration::from_secs(30)),
);
async fn handle_timeout_error(err: BoxError) -> (StatusCode, String) {
if err.is::<tower::timeout::error::Elapsed>() {
(StatusCode::REQUEST_TIMEOUT, "Request took too long".into())
} else {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Unhandled: {err}"))
}
}
HandleErrorLayer can also run extractors (Method, A teammate, …) with the error as the last argument.
§VI. Middleware is Tower
middleware: axum has no private middleware stack. It uses tower::Service, so tower and tower-http layers (trace, CORS, compression, timeouts, request ids) apply directly.
Apply middleware with Router::layer / route_layer, MethodRouter::layer, or Handler::layer. Prefer one ServiceBuilder over repeated .layer calls so ordering stays top-to-bottom and readable.
Onion rule for stacked .layer(a).layer(b).layer(c): requests hit c first, then b, then a, then the handler; responses climb back out. ServiceBuilder::new().layer(a).layer(b).layer(c) runs a then b then c then the handler ; usually easier to keep in your head.
For app-local middleware, axum::middleware::from_fn is the familiar async form. Pass state with from_fn_with_state. To hand data to handlers, insert into request extensions and extract with Extension (as in the auth example in the docs). Middleware added with Router::layer runs after routing, so URI rewrites must wrap the whole Router as a Service before serve.
Fallible middleware still needs HandleErrorLayer above it so hyper never sees a raw error.
§VII. Lab
Crate axum_router_lab under crate/:
GET /items/{id};Path<u32>+State<AppState>; returns JSON{ "id", "label" }from an in-memory map.- Missing id → custom
AppError::NotFound→404with a short body (IntoResponse). from_fnmiddleware insertsx-request-id: labon every response.- Tests use
oneshotRequests against the router (no listen). Assert status, JSON body, and the header.
Paste only output you actually ran on the lab Mac.
§VIII. Contributor takeaway
When you read an Axum PR or issue, check three seams first: extractor failure vs handler Err that is still IntoResponse; whether a new layer's error type is Infallible or needs HandleErrorLayer; and whether a URI-touching layer wraps the whole router or sits behind routing. Those three mismatches cause most "it 500s / it never hits my handler / timeout closes the socket" reports.
§IX. Boundary
One Duha bundle. Phase A Foundations row 2. Next A-day (Sun): h3s one-resource CRUD. No Nix Beat B. No Dhuhr/Maghrib/VMIP. Claude Code was not logged in on the lab Mac (claude -p → "Not logged in · Please run /login"); a teammate self-authored under the pinch rule (Evan 2026-10-14 08:34 ET). Grounding table is in the close note.