Website Backend Development with Rust (Axum)
Axum is an HTTP framework from the Tokio ecosystem, created by the Tokio team. Its difference from Actix Web is architectural proximity to Tower middleware stack and more idiomatic async Rust. Extraction from request is typed at the type system level: if the compiler passes — the request is valid. If not — the code is wrong, not the runtime.
Architectural differences from Actix
Actix runs on its own actor runtime (historically). Axum sits directly on Tokio, simplifying integration with the rest of the ecosystem: tower, tower-http, tracing. No separate worker threads — everything in one Tokio runtime. This is convenient for writing tests and when using gRPC via tonic.
Basic structure
// main.rs
use axum::{routing::{get, post}, Router};
use sqlx::PgPool;
use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer, compression::CompressionLayer};
mod config;
mod errors;
mod handlers;
mod models;
mod middleware;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub config: Arc<config::Config>,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
.init();
let cfg = Arc::new(config::Config::from_env());
let pool = PgPool::connect(&cfg.database_url).await.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let state = AppState { db: pool, config: cfg };
let app = Router::new()
.nest("/api/v1", api_routes())
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
fn api_routes() -> Router<AppState> {
Router::new()
.nest("/users", handlers::users::router())
.nest("/products", handlers::products::router())
}
Extractors — key Axum concept
// handlers/users.rs
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{errors::AppError, models::User, AppState};
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_users).post(create_user))
.route("/:id", get(get_user).put(update_user).delete(delete_user))
}
#[derive(Deserialize)]
pub struct ListParams {
pub page: Option<u32>,
pub per_page: Option<u32>,
pub search: Option<String>,
}
async fn list_users(
State(state): State<AppState>,
Query(params): Query<ListParams>,
) -> Result<impl IntoResponse, AppError> {
let page = params.page.unwrap_or(1).max(1);
let per_page = params.per_page.unwrap_or(25).min(100);
let offset = (page - 1) * per_page;
let users = sqlx::query_as!(
User,
r#"
SELECT * FROM users
WHERE ($1::text IS NULL OR email ILIKE '%' || $1 || '%')
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
params.search,
per_page as i64,
offset as i64
)
.fetch_all(&state.db)
.await?;
Ok(Json(users))
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::not_found("user not found"))?;
Ok(Json(user))
}
#[derive(Deserialize)]
pub struct CreateUserPayload {
pub email: String,
pub name: String,
pub password: String,
}
async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserPayload>,
) -> Result<impl IntoResponse, AppError> {
// validation
if payload.email.is_empty() || !payload.email.contains('@') {
return Err(AppError::validation("invalid email"));
}
let hash = tokio::task::spawn_blocking(move || {
bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST)
})
.await
.unwrap()
.map_err(|_| AppError::internal("hash failed"))?;
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (id, email, name, password_hash)
VALUES ($1, $2, $3, $4)
RETURNING *
"#,
Uuid::new_v4(),
payload.email,
payload.name,
hash
)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(user)))
}
Tower middleware
// middleware/auth.rs
use axum::{
extract::Request,
http::header::AUTHORIZATION,
middleware::Next,
response::Response,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use crate::{errors::AppError, models::Claims};
pub async fn require_auth(
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
let token = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::unauthorized())?;
let secret = std::env::var("JWT_SECRET").unwrap();
let claims = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| AppError::unauthorized())?
.claims;
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
Connecting middleware to individual routes:
use axum::middleware;
fn api_routes() -> Router<AppState> {
let protected = Router::new()
.nest("/orders", handlers::orders::router())
.route_layer(middleware::from_fn(middleware::auth::require_auth));
Router::new()
.nest("/auth", handlers::auth::router())
.merge(protected)
}
Response streaming
use axum::response::sse::{Event, Sse};
use futures_util::stream;
use tokio_stream::StreamExt;
async fn stream_events(
State(state): State<AppState>,
) -> Sse<impl futures_util::Stream<Item = Result<Event, axum::Error>>> {
let stream = stream::iter(0..)
.throttle(std::time::Duration::from_secs(1))
.map(|i| {
Ok(Event::default()
.data(format!("event #{i}"))
.event("tick"))
});
Sse::new(stream).keep_alive(
axum::response::sse::KeepAlive::new()
.interval(std::time::Duration::from_secs(15))
)
}
Testing without running server
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
#[tokio::test]
async fn test_get_user_not_found() {
let app = create_test_app().await;
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/users/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
Development timeline
Axum is slightly faster to develop than Actix Web due to simpler middleware model. REST API of medium complexity (8–12 resources, JWT, PostgreSQL, basic tests): 2–3 weeks. Adding WebSocket, SSE, gRPC (via tonic) and load testing: another 1–2 weeks. First Rust project in a team without language experience requires budgeting 30–50% more time.







