Skip to main content

Building Web Services in Rust: Axum Complete Guide

Created: January 1, 0001 Larry Qu 6 min read

Introduction

Rust is increasingly becoming the language of choice for building high-performance web services. Axum, built on Tokio, provides a minimal and composable framework for web applications with strong type safety and memory safety guarantees.

This guide covers building production-ready web services using Axum, from basics to advanced patterns.


Why Rust for Web Services?

Performance Benefits

Request latency comparison (p99):
- Node.js:     25-50ms
- Python:      100-200ms
- Go:          10-20ms
- Rust (Axum): 5-15ms

Memory usage per 1000 concurrent connections:
- Node.js:     200-300 MB
- Python:      400-600 MB
- Go:          50-100 MB
- Rust:        20-50 MB

Safety Benefits

Rust eliminates entire categories of bugs:
- Memory leaks: Impossible by design
- Null pointer dereferences: No null type
- Data races: Compile-time prevention
- Buffer overflows: Automatic bounds checking

Axum Framework Overview

What is Axum?

Axum = Tokio + Tower ecosystem
- Tokio: Async runtime for concurrency
- Tower: Middleware and service abstraction
- Axum: Web framework on top

Architecture:
Router -> Middleware -> Handler -> Response

Installation

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tower = "0.4"
tower-http = { version = "0.5", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"

Hello World Application

Basic Server

use axum::{
    routing::get,
    Router,
};
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    // Build router with routes
    let app = Router::new()
        .route("/", get(hello_world));

    // Bind and serve
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    
    println!("Listening on {}", addr);
    axum::serve(listener, app).await.unwrap();
}

async fn hello_world() -> &'static str {
    "Hello, World!"
}

Run the Server

cargo run
# Listening on 127.0.0.1:3000

# In another terminal:
curl http://127.0.0.1:3000
# Hello, World!

Routing & Handlers

Route Patterns

use axum::routing::{get, post, put, delete};

let app = Router::new()
    // Simple routes
    .route("/", get(index))
    .route("/users", get(list_users).post(create_user))
    
    // Path parameters
    .route("/users/:id", get(get_user).put(update_user))
    
    // Nested routes
    .nest("/api", api_routes())
    
    // Fallback for 404
    .fallback(not_found);

async fn index() -> &'static str {
    "Welcome"
}

async fn list_users() -> &'static str {
    "List of users"
}

async fn get_user(Path(id): Path<i32>) -> String {
    format!("User: {}", id)
}

Extract Types from Requests

use axum::{
    extract::{Path, Query, Json},
    http::StatusCode,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct ListQuery {
    limit: Option<i32>,
    offset: Option<i32>,
}

#[derive(Serialize, Deserialize)]
struct User {
    id: i32,
    name: String,
    email: String,
}

async fn list_users_with_pagination(
    Query(params): Query<ListQuery>,
) -> Json<Vec<User>> {
    let limit = params.limit.unwrap_or(10);
    let offset = params.offset.unwrap_or(0);
    
    Json(vec![
        User {
            id: 1,
            name: "Alice".to_string(),
            email: "[email protected]".to_string(),
        },
    ])
}

async fn create_user(
    Json(payload): Json<User>,
) -> (StatusCode, Json<User>) {
    (StatusCode::CREATED, Json(payload))
}

State Management

Application State

use axum::extract::State;
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    db: Arc<Database>,
    cache: Arc<Cache>,
}

let state = AppState {
    db: Arc::new(Database::new()),
    cache: Arc::new(Cache::new()),
};

let app = Router::new()
    .route("/data", get(get_data))
    .with_state(state);

async fn get_data(
    State(state): State<AppState>,
) -> String {
    format!("DB: {:?}, Cache: {:?}", state.db, state.cache)
}

Database Connection Pool

use sqlx::postgres::PgPool;

#[derive(Clone)]
struct AppState {
    pool: PgPool,
}

#[tokio::main]
async fn main() {
    let pool = PgPool::connect("postgres://localhost/mydb")
        .await
        .expect("Failed to create pool");

    let state = AppState { pool };

    let app = Router::new()
        .route("/users/:id", get(get_user_from_db))
        .with_state(state);

    // ... serve app
}

async fn get_user_from_db(
    State(state): State<AppState>,
    Path(id): Path<i32>,
) -> Json<User> {
    let user = sqlx::query_as::<_, User>(
        "SELECT id, name, email FROM users WHERE id = $1"
    )
    .bind(id)
    .fetch_one(&state.pool)
    .await
    .unwrap();

    Json(user)
}

Error Handling

Custom Error Type

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
};

#[derive(Debug)]
pub enum ApiError {
    NotFound,
    BadRequest(String),
    InternalError,
    Unauthorized,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "Resource not found"),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, &msg),
            ApiError::InternalError => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
            ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
        };

        let body = Json(json!({
            "error": error_message
        }));

        (status, body).into_response()
    }
}

async fn get_user(
    Path(id): Path<i32>,
) -> Result<Json<User>, ApiError> {
    if id < 0 {
        return Err(ApiError::BadRequest("ID must be positive".to_string()));
    }

    // Try to find user
    let user = find_user(id)
        .await
        .ok_or(ApiError::NotFound)?;

    Ok(Json(user))
}

Middleware

Built-in Middleware

use tower_http::{
    trace::{TraceLayer, DefaultMakeSpan},
    cors::CorsLayer,
};
use std::time::Duration;

let app = Router::new()
    .route("/", get(handler))
    .layer(TraceLayer::new_for_http())
    .layer(
        CorsLayer::permissive()
    );

Custom Middleware

use tower::middleware::Next;
use http::Request;

async fn timing_middleware<B>(
    req: Request<B>,
    next: Next,
) -> impl IntoResponse {
    let start = Instant::now();
    let response = next.run(req).await;
    let elapsed = start.elapsed();
    
    println!("Request took: {:?}", elapsed);
    response
}

let app = Router::new()
    .route("/", get(handler))
    .layer(axum::middleware::from_fn(timing_middleware));

Real-World Example: Todo API

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post, put, delete},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;

#[derive(Clone, Serialize, Deserialize)]
struct Todo {
    id: u32,
    title: String,
    completed: bool,
}

#[derive(Clone)]
struct AppState {
    todos: Arc<RwLock<Vec<Todo>>>,
}

#[tokio::main]
async fn main() {
    let state = AppState {
        todos: Arc::new(RwLock::new(vec![])),
    };

    let app = Router::new()
        .route("/todos", get(list_todos).post(create_todo))
        .route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    
    axum::serve(listener, app).await.unwrap();
}

async fn list_todos(State(state): State<AppState>) -> Json<Vec<Todo>> {
    let todos = state.todos.read().await;
    Json(todos.clone())
}

async fn create_todo(
    State(state): State<AppState>,
    Json(mut todo): Json<Todo>,
) -> (StatusCode, Json<Todo>) {
    todo.id = rand::random();
    state.todos.write().await.push(todo.clone());
    (StatusCode::CREATED, Json(todo))
}

async fn get_todo(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<Todo>, StatusCode> {
    let todos = state.todos.read().await;
    todos
        .iter()
        .find(|t| t.id == id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

async fn update_todo(
    State(state): State<AppState>,
    Path(id): Path<u32>,
    Json(updated): Json<Todo>,
) -> StatusCode {
    let mut todos = state.todos.write().await;
    if let Some(todo) = todos.iter_mut().find(|t| t.id == id) {
        *todo = updated;
        StatusCode::OK
    } else {
        StatusCode::NOT_FOUND
    }
}

async fn delete_todo(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> StatusCode {
    let mut todos = state.todos.write().await;
    if todos.iter().position(|t| t.id == id).is_some() {
        todos.retain(|t| t.id != id);
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

Performance Optimization

Connection Pooling

// Use connection pools, not individual connections
let pool = PgPool::builder()
    .max_connections(50)
    .build("postgres://localhost/mydb")
    .await?;

Caching

use std::sync::Arc;
use std::collections::HashMap;

#[derive(Clone)]
struct AppState {
    cache: Arc<RwLock<HashMap<String, String>>>,
}

async fn get_with_cache(
    State(state): State<AppState>,
    Path(key): Path<String>,
) -> Result<String, StatusCode> {
    // Check cache first
    if let Some(value) = state.cache.read().await.get(&key) {
        return Ok(value.clone());
    }

    // Cache miss - fetch from DB
    let value = fetch_from_db(&key).await?;
    state.cache.write().await.insert(key, value.clone());
    Ok(value)
}

Testing

Unit Tests

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::to_bytes;
    use http::StatusCode;

    #[tokio::test]
    async fn test_hello_world() {
        let response = hello_world().await;
        assert_eq!(response, "Hello, World!");
    }

    #[tokio::test]
    async fn test_create_todo() {
        let state = AppState {
            todos: Arc::new(RwLock::new(vec![])),
        };

        let todo = Todo {
            id: 1,
            title: "Test".to_string(),
            completed: false,
        };

        let (status, _) = create_todo(State(state.clone()), Json(todo)).await;
        assert_eq!(status, StatusCode::CREATED);
    }
}

Deployment

Production Checklist

✅ Error handling for all endpoints
✅ Logging and tracing enabled
✅ Database connection pooling configured
✅ CORS policy defined
✅ Request validation implemented
✅ Rate limiting enabled
✅ Health check endpoint
✅ Graceful shutdown handling
✅ Environment configuration loaded from .env
✅ Tests passing (cargo test)

Glossary

  • Axum: Ergonomic web framework built on Tokio
  • Tokio: Async runtime for Rust
  • Tower: Modular middleware and service abstractions
  • Extract: Types that can be extracted from requests
  • Middleware: Functions that process every request/response
  • Handler: Async function that processes a request

Resources


Comments

Share this article

Scan to read on mobile