HTTP Service Patterns with axum
Use this recipe when generating an HTTP API service. Prefer this for general-purpose Rust HTTP APIs when the user has not specified another framework.
Dependencies
Verify current versions before generating. Current known pattern (verified 2026-08-26; TimeoutLayer::with_status_code requires tower-http ≥ 0.6.7 — new(duration) is deprecated):
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] }
tower-http = { version = "0.7", features = ["trace", "timeout", "cors", "request-id", "util"] }
tracing = "0.1"
serde = { version = "1", features = ["derive"] }Do not enable broad feature sets unless the endpoint needs them.
Path Syntax (axum 0.8+)
Path parameters use {id} and wildcards use {*rest}. The older /:id and /*rest syntax panics at router build time (startup failure, not a silent mismatch). Note that Option<T> extractors now require the OptionalFromRequestParts trait — rejections are no longer silently swallowed into None.
Router Shape
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use std::{net::SocketAddr, sync::Arc, time::Duration};
use tower_http::{timeout::TimeoutLayer, trace::TraceLayer};
#[derive(Clone)]
pub struct AppState {
service: Arc<UserService>,
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(health))
.route("/readyz", get(readiness))
.route("/users", post(create_user))
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(30),
))
}
async fn health() -> &'static str {
"ok"
}
async fn readiness(State(_state): State<AppState>) -> Result<&'static str, ApiError> {
Ok("ready")
}
async fn create_user(
State(state): State<AppState>,
Json(request): Json<CreateUserRequest>,
) -> Result<impl IntoResponse, ApiError> {
let user = state.service.create_user(request).await?;
Ok((StatusCode::CREATED, Json(user)))
}
pub async fn serve(address: SocketAddr, state: AppState) -> Result<()> {
let listener = tokio::net::TcpListener::bind(address)
.await
.map_err(|error| ServiceError::invalid_input(format!("failed to bind {address}: {error}")))?;
axum::serve(listener, router(state))
.with_graceful_shutdown(shutdown_signal())
.await
.map_err(|error| ServiceError::invalid_input(format!("HTTP server failed: {error}")))?;
Ok(())
}
async fn shutdown_signal() {
if let Err(error) = tokio::signal::ctrl_c().await {
tracing::warn!(?error, "failed to listen for shutdown signal");
}
}Error Response Boundary
#[derive(Debug)]
pub struct ApiError(ServiceError);
impl From<ServiceError> for ApiError {
fn from(error: ServiceError) -> Self {
Self(error)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self.0 {
ServiceError::NotFound { .. } => (StatusCode::NOT_FOUND, "resource not found"),
ServiceError::InvalidInput { .. } => (StatusCode::BAD_REQUEST, "invalid request"),
_ => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error"),
};
(status, Json(ErrorBody { message })).into_response()
}
}
#[derive(serde::Serialize)]
struct ErrorBody {
message: &'static str,
}Rules:
- Map domain errors to HTTP responses at the API boundary.
- Do not leak internal error details to callers.
- Add request tracing and timeouts.
- Keep health checks fast and dependency-light.
- Keep readiness checks bounded by short timeouts.
- Put shared state in a typed
AppState; avoid global mutable state. - Use graceful shutdown for container and orchestrator compatibility.