irahardianto / axum-idioms
Axum (0.8+) web framework patterns: type-safe extractors, Tower middleware, route routing, and graceful shutdown. Use when implementing HTTP APIs or web services in Rust. Pair with rust-idioms.
Axum Idioms and Patterns
Core Philosophy
Axum (0.8+) rewards composability via Tower, type-safe extractors, and zero-cost abstractions. Idiomatic Axum = thin handlers, tower middleware, typed errors.
Version note: This skill targets Axum 0.8+ (released 2025). Key changes from 0.7: path parameter syntax changed from
:nameto{name},Stateextractor is now inaxum::extract, andaxum::servereplacesaxum::Server. If you encounter an existing codebase on 0.7, check the Axum 0.8 changelog before applying these patterns.
Scope: Axum-specific patterns. For Rust fundamentals: @.agents/skills/rust-idioms/SKILL.md. For project structure: @.agents/skills/rust-idioms/references/project-structure.md.
Router and Route Organization
Build routers with
Router::new()and method routing:`rust
// ✅ Group by resource, nest for versioning
fn task_routes() -> Router<AppState> {
Router::new()
.route("/tasks", get(listtasks).post(createtask))
.route("/tasks/{id}", get(gettask).put(updatetask).delete(delete_task))
}fn app(state: Arc<AppState>) -> Router {
let api = Router::new()
.merge(task_routes()) // .merge() combines peer routers
.merge(user_routes());Router::new()
.nest("/api/v1", api) // .nest() adds prefix to sub-router
.fallback(handle_404) // typed error, not Axum's default plain-text 404
.with_state(state)
}`- Path parameters use
{name}syntax (not:name).
Extractors
Built-in extractors —
Path,Query,Json,State,HeaderMap:`rust
// ✅ Typed and validated at compile time
async fn get_task(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<TaskResponse>, AppError> {
let task = state.task_service.find(id).await?;
Ok(Json(task.into()))
}`Extractor ordering — body-consuming extractors MUST be last:
`rust
// ✅ Path before Json (Json consumes the body)
async fn update_task(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateTaskRequest>,
) -> Result<Json<TaskResponse>, AppError> { ... }// ❌ Json before Path — won't compile or will fail at runtime
async fn update_task(Json(body): Json<UpdateTaskRequest>, Path(id): Path<Uuid>) { ... }`Custom extractors via
FromRequestParts(non-body) orFromRequest(body):`rust
// ✅ Custom extractor for authenticated user
impl<S: Send + Sync> FromRequestParts<S> for AuthUser {
type Rejection = AppError;
async fn fromrequestparts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let token = parts.headers.get(AUTHORIZATION)
.andthen(|v| v.tostr().ok())
.andthen(|v| v.stripprefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
decodejwt(token).maperr(|_| AppError::Unauthorized)
}
}`- Handle rejections with custom types — never let Axum's default messages leak to clients.
Application State
Wrap in
Arc, pass viaState:`rust
pub struct AppState {
pub db: sqlx::PgPool,
pub task_service: TaskService,
pub config: AppConfig,
}let state = Arc::new(AppState { db: pool, task_service, config });
let app = Router::new().route("/tasks", get(listtasks)).withstate(state);`- Compose state for feature isolation — each feature defines its own state struct, combined at app level.
- Never clone
AppStatedirectly — wrap inArc, clone theArc.
Middleware (Tower)
ServiceBuilderfor layer composition:`rust
// ✅ Full middleware stack — order matters (outermost runs first)
let app = Router::new()
.nest("/api/v1", api_routes())
.layer(
ServiceBuilder::new()
.layer(TraceLayer::newforhttp())
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive()) // development ONLY — see below
.layer(TimeoutLayer::new(Duration::from_secs(30)))
)
.with_state(state);`Never ship
CorsLayer::permissive()to production. It allows any origin, any method, any header, and sends noAccess-Control-Allow-Credentials. Use an explicit, allow-listed layer instead. Load allowed origins from config, not literals:use tower_http::cors::CorsLayer; use http::HeaderValue; // ✅ Production-safe — explicit allow-list fn cors_layer(allowed_origins: &[String]) -> CorsLayer { let origins: Vec<HeaderValue> = allowed_origins .iter() .filter_map(|o| HeaderValue::try_from(o).ok()) .collect(); CorsLayer::new() .allow_origin(origins) // explicit list, NEVER Any in prod .allow_methods([ // methods you actually serve axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE, ]) .allow_headers([ axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE, axum::http::header::ACCEPT, ]) .allow_credentials(true) // required for cookies / auth .max_age(Duration::from_secs(3600)) // cache preflight 1h } // .layer(cors_layer(&config.cors.allowed_origins))Rule:
allow_credentials(true)is incompatible withallow_origin(Any)— browsers reject it. If you need credentials, you MUST enumerate origins. Seesecurity-principles.md§CORS.Custom middleware with
from_fn:`rust
async fn auth_middleware(
State(state): State<Arc<AppState>>,
request: Request,
next: Next,
) -> Result<Response, AppError> {
let token = request.headers().get(AUTHORIZATION)
.andthen(|v| v.tostr().ok())
.andthen(|v| v.stripprefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
state.auth_service.validate(token).await?;
Ok(next.run(request).await)
}// Apply to specific routes with route_layer
let protected = Router::new()
.route("/tasks", get(list_tasks))
.routelayer(middleware::fromfnwithstate(state.clone(), auth_middleware));`- Layer ordering:
ServiceBuilderapplies bottom-to-top — last.layer()wraps closest to handler. Propagate correlation IDs with
TraceLayer:`rust
use tower_http::trace::TraceLayer;
use tracing::Span;// ✅ Inject x-request-id into every span for log correlation
let tracelayer = TraceLayer::newfor_http()
.makespanwith(|request: &Request<_>| {
let request_id = request
.headers()
.get("x-request-id")
.andthen(|v| v.tostr().ok())
.map(String::from)
.unwraporelse(|| uuid::Uuid::newv4().tostring());
tracing::info_span!(
"request",
method = %request.method(),
uri = %request.uri(),
requestid = %requestid,
)
});let app = Router::new()
.nest("/api/v1", api_routes())
.layer(trace_layer)
.with_state(state);`All
tracing::info!,warn!,error!calls inside a handler will automatically inherit the span fields above (method, uri, requestid). This satisfies thecorrelationIdrequirement from the Logging Mandate. Seelogging-implementation/SKILL.md§Rust for the full `inittracing()` setup.Request body size limits — prevent denial-of-service via large payloads:
`rust
use axum::extract::DefaultBodyLimit;let app = Router::new()
.nest("/api/v1", api_routes())
.layer(DefaultBodyLimit::max(1024 * 1024)) // 1 MB global limit
.with_state(state);// Per-route override for file uploads:
let upload_routes = Router::new()
.route("/upload", post(upload_file))
.layer(DefaultBodyLimit::max(50 * 1024 * 1024)); // 50 MB for uploads`
Error Handling
Unified
AppErrorenum withIntoResponse:`rust
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found: {0}")] NotFound(String),
#[error("validation failed: {0}")] Validation(String),
#[error("unauthorized")] Unauthorized,
#[error("forbidden")] Forbidden,
#[error(transparent)] Internal(#[from] anyhow::Error),
}impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, msg) = match &self {
Self::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
Self::Validation(m) => (StatusCode::UNPROCESSABLE_ENTITY, m.clone()),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden".into()),
Self::Internal(e) => {
tracing::error!(error = %e, "internal server error");
(StatusCode::INTERNALSERVERERROR, "internal server error".into())
}
};
(status, Json(serdejson::json!({ "error": msg }))).intoresponse()
}
}`- Handler return type: always
Result<impl IntoResponse, AppError>. - Convert rejections into
AppErrorfor consistent JSON error shape.
Response Types
Json<T>for standard responses,(StatusCode, Json<T>)tuple for non-200:`rust
async fn list_tasks(...) -> Result<Json<Vec<TaskResponse>>, AppError> { ... }
async fn create_task(...) -> Result<(StatusCode, Json<TaskResponse>), AppError> {
Ok((StatusCode::CREATED, Json(task.into())))
}`Responsebuilder for headers, streaming, or non-JSON (CSV, files).
Validation
validatorcrate with#[derive(Validate)]:`rust
#[derive(Debug, Deserialize, Validate)]
pub struct CreateTaskRequest {
#[validate(length(min = 1, max = 255))]
pub title: String,
#[validate(range(min = 1, max = 5))]
pub priority: u8,
}`Custom
ValidatedJson<T>extractor — implementFromRequest<S>that deserializes viaJson<T>then callsvalue.validate(), converting failures toAppError::Validation:`rust
use axum::extract::{FromRequest, Request};
use axum::Json;
use serde::de::DeserializeOwned;
use validator::Validate;pub struct ValidatedJson<T>(pub T);
impl<S, T> FromRequest<S> for ValidatedJson<T>
where
S: Send + Sync,
T: DeserializeOwned + Validate,
{
type Rejection = AppError;async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state)
.await
.maperr(|e| AppError::Validation(e.tostring()))?;
value.validate().maperr(|e| AppError::Validation(e.tostring()))?;
Ok(ValidatedJson(value))
}
}// Usage — replaces bare Json<T> in handler signatures
async fn create_task(
State(state): State<Arc<AppState>>,
ValidatedJson(body): ValidatedJson<CreateTaskRequest>,
) -> Result<(StatusCode, Json<TaskResponse>), AppError> {
let task = state.task_service.create(body).await?;
Ok((StatusCode::CREATED, Json(task.into())))
}`
Response Types and Domain Conversion
Separate request and response types — never expose domain models directly to the API:
`rust
// --- Request type (deserialize + validate) ---
#[derive(Debug, Deserialize, Validate)]
#[serde(rename_all = "camelCase")]
pub struct CreateTaskRequest {
#[validate(length(min = 1, max = 255))]
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[validate(range(min = 1, max = 5))]
#[serde(default = "default_priority")]
pub priority: u8,
}fn default_priority() -> u8 { 3 }
// --- Response type (serialize) ---
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskResponse {
pub id: Uuid,
pub title: String,
#[serde(skipserializingif = "Option::is_none")]
pub description: Option<String>,
pub priority: u8,
pub created_at: DateTime<Utc>,
}// --- Domain → Response conversion ---
impl From<Task> for TaskResponse {
fn from(task: Task) -> Self {
Self {
id: task.id,
title: task.title,
description: task.description,
priority: task.priority,
createdat: task.createdat,
}
}
}`Use
.into()in handlers for clean conversion:`rust
async fn get_task(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<TaskResponse>, AppError> {
let task = state.task_service.find(id).await?;
Ok(Json(task.into())) // From<Task> for TaskResponse
}`- For serde attribute patterns (renameall, denyunknownfields, skipserializing_if), see
rust-idioms/references/serde-patterns.md.
Testing
For universal testing principles, see
.agents/rules/testing-strategy.md. Below: Axum-specific patterns only.
tower::ServiceExt::oneshot— test handlers without spawning a server:`rust
#[tokio::test]
async fn testcreatetaskreturns201() {
let app = app(Arc::new(testappstate().await));
let response = app.oneshot(
Request::builder().method("POST").uri("/api/v1/tasks")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"Test","priority":3}"#)).unwrap(),
).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
}`Mock state helpers — inject trait-based test doubles for isolation:
`rust
/// Build test AppState with mock services (no real DB needed)
fn testappstate() -> Arc<AppState> {
let mocktaskservice = MockTaskService::new(); // implements TaskService trait
Arc::new(AppState {
taskservice: Box::new(mocktask_service),
config: test_config(),
})
}`This follows the trait-based DI pattern from
@.agents/rules/architectural-pattern.md— swap real I/O implementations for test doubles at theAppStatelevel.towerdev-dependency required — addtower = { version = "0.5", features = ["util"] }to[dev-dependencies]to useServiceExt::oneshot.- Integration tests use a real database via
sqlx::testor Testcontainers.
Graceful Shutdown
Container orchestrators (Kubernetes, Docker stop) send SIGTERM, not SIGINT — handle BOTH. Use tokio_util::sync::CancellationToken (the recommended cancellation primitive per @.agents/skills/rust-idioms/SKILL.md §Async and Concurrency) so in-flight handlers and background tasks can observe the shutdown and unwind cooperatively. Axum's with_graceful_shutdown then drains active connections before exiting.
use tokio::signal;
use tokio_util::sync::CancellationToken;
// Wire the token into AppState so background tokio tasks can observe shutdown.
let shutdown = CancellationToken::new();
let state = Arc::new(AppState { /* ... */ shutdown: shutdown.clone() });
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(shutdown.clone()))
.await?;/// ✅ Unified signal handler — fires on SIGINT (ctrl-c) OR SIGTERM (container stop).
async fn shutdown_signal(token: CancellationToken) {
let ctrl_c = async { signal::ctrl_c().await.expect("install ctrl-c handler") };
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler")
.recv().await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => tracing::info!("SIGINT received, shutting down"),
_ = terminate => tracing::info!("SIGTERM received, shutting down"),
}
token.cancel(); // notify background tasks + long-lived handlers to unwind
}Why: Axum stops accepting new connections and drains in-flight requests before exiting. The shared
CancellationTokenlets your background workers (queue consumers, scheduled jobs, long-poll handlers) observe the same shutdown and exit cooperatively instead of being killed mid-write. Addtokio-util(with thertfeature) to your dependencies. Seerust-idioms§Async and Concurrency for the cancellation-safety policy.
Anti-Patterns
- ❌ Business logic in handlers — extract to a service/logic layer; handlers only parse, delegate, respond
- ❌
Extensioninstead ofState—Extensionis untyped and pre-0.6; useState<T>always - ❌ Cloning entire state — wrap in
Arc, clone theArc - ❌ Blocking in async handlers — use
tokio::task::spawn_blockingfor CPU-bound or blocking I/O - ❌ Wrong extractor ordering — body-consuming extractors (
Json,Form) must be the last parameter - ❌ Returning string errors — use typed
AppErrorwithIntoResponsefor consistent error shape - ❌ Leaking internal error details — log with
tracing::error!, return generic message to client
Formatting and Static Analysis
Same tooling as Rust. See @.agents/skills/rust-idioms/SKILL.md#clippy-and-formatting.
Related
- Code Idioms and Conventions @.agents/rules/code-idioms-and-conventions.md
- Rust Idioms @.agents/skills/rust-idioms/SKILL.md
- API Design Principles @.agents/rules/api-design-principles.md
- Security Principles @.agents/rules/security-principles.md
- Error Handling Principles @.agents/rules/error-handling-principles.md
- Architectural Patterns @.agents/rules/architectural-pattern.md
- Testing Strategy @.agents/rules/testing-strategy.md
- Logging and Observability Mandate @.agents/rules/logging-and-observability-mandate.md
- Logging Implementation @.agents/skills/logging-implementation/SKILL.md
- Serde Patterns @.agents/skills/rust-idioms/references/serde-patterns.md
- SQLx Patterns @.agents/skills/rust-idioms/references/sqlx-patterns.md