Skip to main content
โšก Calmops

Building Web Services in Rust: Axum Complete Guide

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