06. Middleware and Layers
📋 Jump to Takeaways🎁 In Go you wrap http.Handler with another http.Handler. In Axum you stack Layers. Same idea, different types — and Rust's type system ensures you can't wire them incorrectly.
How Axum Middleware Works
Axum is built on tower, a library for composable async services. Middleware in tower is a Layer — something that wraps a service and adds behavior. You stack layers onto your router:
use axum::Router;
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/health", get(health))
.layer(TraceLayer::new_for_http()); // wraps all routesLayers apply outside-in: the last .layer() call is the outermost wrapper — it runs first on request and last on response.
tower-http
[dependencies]
tower-http = { version = "0.6", features = ["trace", "cors", "request-id"] }
tower = "0.5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }Structured Logging with tracing
TraceLayer emits structured spans for every request. Set up tracing-subscriber first so the spans are printed:
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn init_tracing() {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
}Call init_tracing() at the top of main before anything else.
Now add TraceLayer:
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/health", get(health))
.layer(TraceLayer::new_for_http());Every request now logs method, path, status, and duration automatically.
Request IDs
Attach a unique ID to every request for log correlation:
use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer, PropagateRequestIdLayer};
use axum::http::HeaderName;
let request_id_header = HeaderName::from_static("x-request-id");
let app = Router::new()
.route("/health", get(health))
.layer(PropagateRequestIdLayer::new(request_id_header.clone()))
.layer(TraceLayer::new_for_http())
.layer(SetRequestIdLayer::new(request_id_header, MakeRequestUuid));SetRequestIdLayer generates a UUID and puts it in the request header. PropagateRequestIdLayer copies it to the response header so clients can trace requests.
CORS
use tower_http::cors::{CorsLayer, Any};
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", get(health))
.layer(cors);In production, replace Any with your actual allowed origins:
use axum::http::HeaderValue;
.allow_origin("https://yourdomain.com".parse::<HeaderValue>().unwrap())Writing Custom Middleware
For simple cases, use axum::middleware::from_fn:
use axum::{middleware::{self, Next}, extract::Request, response::Response};
async fn require_api_key(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let key = request.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok());
match key {
Some(k) if k == "secret" => Ok(next.run(request).await),
_ => Err(StatusCode::UNAUTHORIZED),
}
}
// Apply only to specific routes:
let protected = Router::new()
.route("/links", post(create_link))
.route_layer(middleware::from_fn(require_api_key));
let app = Router::new()
.route("/health", get(health))
.merge(protected);route_layer applies middleware only to the routes in that router. .layer() applies it to everything.
Key Takeaways
- Axum middleware uses tower's
Layertrait — stack them with.layer() - Layers apply outside-in: the last
.layer()is the outermost wrapper tower-httpprovides production-ready layers: tracing, CORS, request IDs, compressionTraceLayerlogs method, path, status, and duration for every request automaticallyfrom_fnis the simplest way to write custom middlewareroute_layerapplies middleware only to specific routes;.layer()applies to all
🎁 Your service handles requests, but the data doesn't persist anywhere. Every restart loses everything. Next up: connecting to PostgreSQL with SQLx — async, type-safe, and with compile-time query checking.