Axum Complete Guide | High-Performance Rust Web Server & REST API
이 글의 핵심
Axum is a Rust web framework on Tokio, Tower, and Hyper. This guide walks through routing, extractors, middleware, state, errors, WebSocket, and REST API structure in one flow.
What this article covers
Axum is a popular framework for HTTP services in Rust. It sits on the Tokio async runtime, Tower’s Service layer, and the Hyper HTTP implementation, and its hallmark is the Extractor model—“parse the request with types.” Here we cover routing and handlers, extractors and middleware, application state, error responses, WebSocket, and a practical REST API layout in one place. If you have already read Rust async & Tokio, that background maps directly to learning Axum.
1. What is Axum?
1.1 Stack
Axum does not reimplement everything itself. Requests arrive through Hyper, async execution is Tokio’s job, and cross-cutting concerns such as middleware, timeouts, and retries compose via Tower’s Service and Layer. Long term, understanding Tower patterns and async Rust together pays off more than learning “Axum only.”
1.2 Design philosophy
- Type-safe routing: Combine path parameters, query strings, and bodies with extractors so parsing rules can be checked at compile time.
- Nested routers: Split APIs by module with
mergeandnestonRouter. - Consistent middleware: Tower layers plug in directly, so CORS, tracing, compression, and more attach the same way.
1.3 When to choose Axum
It fits HTTP, JSON, and streaming-centric services: microservice APIs, BFFs, internal admin tools, and realtime endpoints mixing WebSocket. If you only need a classic multi-page server driven by a template engine and SSR, other stacks may be simpler.
2. Project setup and dependencies
2.1 New project
cargo new axum-demo --bin
cd axum-demo
2.2 Sample Cargo.toml
Below is a minimal setup for REST, CORS, logging, and WebSocket in one go. In production, pin versions and run cargo audit regularly.
[package]
name = "axum-demo"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7", features = ["macros", "ws"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.5", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
features = ["macros"] enables route macros such as #[derive(axum::routing::get)]; ws is required for WebSocket handlers.
3. Minimal server and routing
3.1 Hello World
Attach paths and methods to Router, bind with TcpListener, and pass the app to axum::serve. Using tokio::main for an async entry point is the usual pattern.
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, Axum" }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
The closure passed to get is an async block, so handlers return a Future. The return type must implement IntoResponse; a string slice becomes a 200 OK text response automatically.
3.2 Multiple routes and HTTP methods
use axum::{
routing::{get, post},
Router,
};
async fn list_users() -> &'static str {
"user list"
}
async fn create_user() -> &'static str {
"user created"
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users", get(list_users).post(create_user));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
To attach both GET and POST on the same path, chain with get(...).post(...) on one line. For REST APIs, grouping by resource keeps routes readable.
4. Handlers and extractors
4.1 What is an extractor?
In Axum, an extractor “pulls values out of the request and turns them into types.” Argument order and types define the parsing rules. For example, Path handles path parameters, Query the query string, and Json the JSON body.
4.2 Path and Json
use axum::{
extract::Path,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
}
async fn get_user(Path(user_id): Path<u64>) -> Json<User> {
Json(User {
id: user_id,
name: "demo".into(),
})
}
async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
Json(User {
id: 1,
name: payload.name,
})
}
fn user_routes() -> Router {
Router::new()
.route("/users/:id", get(get_user))
.route("/users", post(create_user))
}
Path must match segment names (:id) and types. If u64 parsing fails, Axum responds with 400 Bad Request; for custom types or validation, consider a serde pipeline or a custom extractor. Json handles Content-Type: application/json and body parsing; deserialization failures are also client errors.
4.3 Combining extractors
Extractors can be grouped in tuples—for example Path, then Query, then HeaderMap. Consuming extractors (types that read the body once) are limited to one per handler, so avoid designs that read the body twice.
5. Middleware
5.1 Tower Layer
Axum wraps Tower’s Layer with Router::layer. CORS, request logging, timeouts, and more usually come from tower-http.
use axum::Router;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
fn app() -> Router {
Router::new()
.route("/health", axum::routing::get(|| async { "ok" }))
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
)
.layer(TraceLayer::new_for_http())
}
In production, prefer an explicit allowlist of origins over allow_origin(Any). TraceLayer integrates with tracing to log method, path, and status code.
5.2 Custom middleware
axum::middleware::from_fn can turn an async function into a layer—useful for auth headers, request IDs, rate limiting, and more.
use axum::{
body::Body,
extract::Request,
middleware::Next,
response::Response,
};
async fn inject_request_id(mut req: Request, next: Next) -> Response {
let id = uuid::Uuid::new_v4().to_string();
req.headers_mut().insert("x-request-id", id.parse().unwrap());
next.run(req).await
}
Next forwards the request to the next layer or handler. To post-process the response, add headers after next.run returns.
6. State management
6.1 AppState pattern
To share a DB pool, configuration, or clients app-wide, implement Clone on a struct and inject it with Router::with_state. Wrap expensive resources in Arc.
use std::sync::Arc;
use axum::{extract::State, routing::get, Router};
#[derive(Clone)]
struct AppState {
db: Arc<String>, // in real code, e.g. sqlx::PgPool
}
async fn handler(State(state): State<AppState>) -> String {
format!("db = {}", state.db)
}
fn app(state: AppState) -> Router {
Router::new()
.route("/", get(handler))
.with_state(state)
}
The State extractor must appear exactly once with a matching type in the handler signature. If sub-routers use different state types, merge generics get awkward; a single AppState with per-module fields is usually simpler.
6.2 Sub-routes and state
When you nest under /api, the same State still applies. If you version APIs, splitting Router definitions across files and mergeing them helps maintenance.
7. Error handling
7.1 Result and IntoResponse
For handlers to return Result<impl IntoResponse, E>, you need E: IntoResponse. A common pattern is one project-wide error type with IntoResponse implemented.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub struct ApiError {
status: StatusCode,
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = Json(json!({
"error": self.message,
}));
(self.status, body).into_response()
}
}
pub type ApiResult<T> = Result<T, ApiError>;
Then handlers can use ? and keep HTTP status and JSON shape consistent.
7.2 Integrating anyhow and thiserror
Model domain errors with thiserror and map them to ApiError at boundaries. Avoid exposing anyhow::Error directly to clients—use generic messages on 500 and log details server-side.
7.3 Fallback handlers
Use Router::fallback to unify 404 JSON responses, or when serving an SPA to always return index.html as needed.
8. WebSocket
8.1 Upgrade flow
Axum’s WebSocket path upgrades HTTP to a bidirectional stream. Enter the handler with the WebSocketUpgrade extractor and pass the real socket work to on_upgrade.
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
response::IntoResponse,
routing::get,
Router,
};
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
while let Some(Ok(msg)) = socket.recv().await {
if let Message::Text(t) = msg {
let _ = socket.send(Message::Text(t)).await;
}
}
}
fn ws_routes() -> Router {
Router::new().route("/ws", get(ws_handler))
}
In production, also design ping/pong timeouts, message size limits, and authentication (query token or cookie). Long-lived connections must align with load balancer idle timeouts.
9. Production REST API structure
9.1 Layered layout
The sketch below assumes router / handler / service (domain) / repository instead of one giant file. Small projects can keep logic in handlers; as tests and churn grow, a service layer pays off.
- Router: URLs, methods, middleware only
- Handler: Extractor wiring,
StatusCode, DTO mapping - Service: Pure logic and transaction boundaries
- Repository: DB and external APIs
9.2 CRUD sketch
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post, delete},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Clone, Default)]
struct AppState {
items: Arc<RwLock<HashMap<u64, Item>>>,
next_id: Arc<RwLock<u64>>,
}
#[derive(Clone, Serialize)]
struct Item {
id: u64,
name: String,
}
#[derive(Deserialize)]
struct CreateItem {
name: String,
}
async fn list_items(State(state): State<AppState>) -> Json<Vec<Item>> {
let map = state.items.read().unwrap();
Json(map.values().cloned().collect())
}
async fn get_item(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Result<Json<Item>, StatusCode> {
let map = state.items.read().unwrap();
map.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}
async fn create_item(
State(state): State<AppState>,
Json(payload): Json<CreateItem>,
) -> (StatusCode, Json<Item>) {
let mut id_lock = state.next_id.write().unwrap();
let id = *id_lock;
*id_lock += 1;
let item = Item {
id,
name: payload.name,
};
state.items.write().unwrap().insert(id, item.clone());
(StatusCode::CREATED, Json(item))
}
async fn delete_item(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> StatusCode {
match state.items.write().unwrap().remove(&id) {
Some(_) => StatusCode::NO_CONTENT,
None => StatusCode::NOT_FOUND,
}
}
fn api_router(state: AppState) -> Router {
Router::new()
.route("/items", get(list_items).post(create_item))
.route("/items/:id", get(get_item).delete(delete_item))
.with_state(state)
}
Here RwLock is demo-only. In production prefer tokio::sync::RwLock with an async database, or a channel-backed single-writer model. The right concurrency model depends on domain needs and measurements.
9.3 Validation and boundaries
Validate input with serde constraints, the validator crate, or custom extractors. Validate once at the boundary and convert to internal domain types so invalid state cannot spread deep into the stack.
10. Operations, performance, pitfalls
10.1 Blocking calls
Running synchronous file I/O or CPU-heavy work for a long time on a Tokio worker can starve other requests. If you must block, use tokio::task::spawn_blocking or a dedicated thread pool.
10.2 Timeouts and body size
Align timeouts and max body size between your reverse proxy (Nginx, Cloudflare) and the app. Infra-level limits are often the real defense line, not Axum/Hyper settings alone.
10.3 Observability
Structured logging with tracing, OpenTelemetry, and /health and /ready endpoints are close to mandatory in operations. Adding a request ID to error responses speeds up incident analysis.
11. Troubleshooting
| Symptom | Common cause | What to check |
|---|---|---|
the trait bound ... is not satisfied | Extractor order or type mismatch | Handler argument types vs with_state generics |
| Only 404s | nest paths or duplicate slashes | Mixing "/api" vs "/api/" |
| WebSocket drops immediately | Proxy does not support upgrade | HTTP/1.1 upgrade and header forwarding |
| Body parse failures | Content-Type or JSON shape | Client sends application/json |
12. Summary
Axum is a Rust web framework that fits naturally into the Tokio and Tower ecosystem. Master routers for URLs, extractors to turn requests into types, state to share resources, and IntoResponse to map errors to HTTP, and you have the skeleton of a real-world API. Add middleware for cross-cutting concerns, WebSocket for realtime channels, and a layered module layout for REST, and you get something close to production shape. Before shipping, run cargo clippy, load tests, and an infra checklist covering security headers and TLS termination.
Deploying: After changes, run git add, git commit, and git push, then npm run deploy to publish to Cloudflare Pages. If a post with the same title or slot already exists, adjust the filename instead of overwriting.