Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 027 / 100

Rust web development with Axum: best practices explained

Build HTTP APIs in Rust.

Expona Admin7 min read

Introduction

Building HTTP APIs in Rust is no longer a niche pursuit—production teams are shipping Axum services to handle millions of requests daily. Rust web development with Axum: best practices explained covers the patterns we've learned the hard way across e-commerce platforms, trading systems, and SaaS backends.

Axum sits on top of Tokio and Tower, giving us async-first routing, type-safe extractors, and middleware composition without the overhead of runtime reflection. This guide walks through routing, extractors, responses, and middleware with the concrete patterns our teams rely on.

Router — Map routes to handlers

The router is where requests begin their journey. Axum's Router::new() chains route definitions and binds them to async handler functions. Each route maps an HTTP method and path pattern to a handler that receives typed arguments.

Let's start with a basic health check and a parameterized endpoint:

rust
use axum::{
    routing::{get, post},
    Router,
};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/health", get(|| async { "ok" }))
        .route("/products/:id", get(get_product))
        .route("/products", post(create_product));

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

async fn get_product(axum::extract::Path(id): axum::extract::Path<u64>) -> String {
    format!("Product {}", id)
}

Notice the handler signature: it takes extractors as arguments and returns a value that implements IntoResponse. Axum inspects the function signature at compile time to wire up the extractors. This is not magic—it's procedural macros and trait bounds doing the heavy lifting.

In production, we organize routers by domain. A trading system might split routes across product routers:

rust
let app = Router::new()
    .nest("/api/products", products_router())
    .nest("/api/orders", orders_router())
    .layer(TraceLayer::new_for_http());

fn products_router() -> Router {
    Router::new()
        .route("/", post(create_product))
        .route("/:id", get(get_product).put(update_product).delete(delete_product))
}

Nesting keeps route definitions modular and makes the main app router readable. Each nested router can have its own middleware stack.

Extractors — Path, Query, Json and State

Extractors are the glue between HTTP and Rust types. They parse request components (path, query, headers, body) into typed structs. The type system ensures safety—invalid data is rejected before your handler runs.

Common extractors:

  • Path: Parse path segments into a struct or tuple
  • Query: Deserialize query parameters
  • Json: Parse and validate JSON body
  • State: Access shared application state (database pool, config)

Here's a realistic example from an e-commerce backend:

rust
use axum::{
    extract::{Path, Query, State},
    Json,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;

#[derive(Serialize, Deserialize)]
struct CreateProductRequest {
    name: String,
    price: f64,
    sku: String,
}

#[derive(Serialize)]
struct Product {
    id: u64,
    name: String,
    price: f64,
}

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

async fn create_product(
    State(db): State<PgPool>,
    Json(payload): Json<CreateProductRequest>,
) -> Json<Product> {
    let id = sqlx::query_scalar::<_, u64>(
        "INSERT INTO products (name, price, sku) VALUES ($1, $2, $3) RETURNING id"
    )
    .bind(&payload.name)
    .bind(payload.price)
    .bind(&payload.sku)
    .fetch_one(&db)
    .await
    .unwrap();

    Json(Product {
        id,
        name: payload.name,
        price: payload.price,
    })
}

async fn list_products(
    State(db): State<PgPool>,
    Query(params): Query<ListQuery>,
) -> Json<Vec<Product>> {
    let limit = params.limit.unwrap_or(10) as i64;
    let offset = params.offset.unwrap_or(0) as i64;

    let products = sqlx::query_as::<_, Product>(
        "SELECT id, name, price FROM products LIMIT $1 OFFSET $2"
    )
    .bind(limit)
    .bind(offset)
    .fetch_all(&db)
    .await
    .unwrap();

    Json(products)
}

Notice that the State extractor passes the database pool into every handler that declares it. This is how we share resources—no global mutable state, no thread-local storage needed. The pool itself is thread-safe and connection pooling is handled by sqlx.

Extractors compose. You can use multiple extractors in one handler, and they're extracted in the order they appear:

rust
async fn update_product(
    State(db): State<PgPool>,
    Path(id): Path<u64>,
    Json(payload): Json<CreateProductRequest>,
) -> Json<Product> {
    // update logic
}

Custom extractors can be built too, but that's beyond this guide. For now, know that if validation fails in a built-in extractor, Axum returns a 400 Bad Request automatically.

Responses — Return Json or impl IntoResponse

Handlers return anything implementing IntoResponse. This trait bridges Rust values to HTTP responses. The most common response types are Json<T>, String, status codes, and tuples combining them.

Simple return types work out of the box:

rust
async fn health() -> &'static str {
    "ok"
}

async fn get_user(Path(id): Path<u64>) -> Json<User> {
    Json(User { id, name: "Alice".to_string() })
}

For error handling and custom status codes, return a tuple:

rust
use axum::http::StatusCode;

async fn delete_product(
    State(db): State<PgPool>,
    Path(id): Path<u64>,
) -> Result<StatusCode, (StatusCode, String)> {
    let rows_affected = sqlx::query(
        "DELETE FROM products WHERE id = $1"
    )
    .bind(id)
    .execute(&db)
    .await
    .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()))?
    .rows_affected();

    if rows_affected == 0 {
        return Err((StatusCode::NOT_FOUND, "Product not found".to_string()));
    }

    Ok(StatusCode::NO_CONTENT)
}

For more complex responses, build a custom IntoResponse implementation. A ride-hailing backend might return paginated results with metadata:

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

#[derive(Serialize)]
struct PaginatedResponse<T> {
    data: Vec<T>,
    total: u64,
    page: u32,
}

impl<T: Serialize> IntoResponse for PaginatedResponse<T> {
    fn into_response(self) -> Response {
        (StatusCode::OK, Json(self)).into_response()
    }
}

This pattern separates concerns: handlers focus on business logic, responses handle HTTP semantics.

Middleware — Tower layers for logging and auth

Middleware wraps handlers and processes requests or responses. Tower's Layer trait provides a clean abstraction. Axum integrates Tower seamlessly, so middleware stacks are composable.

A typical production setup includes logging and authentication:

rust
use axum::{
    extract::Request,
    middleware::{self, Next},
    response::Response,
};
use tower_http::trace::TraceLayer;

async fn auth_middleware(
    State(db): State<PgPool>,
    mut request: Request,
    next: Next,
) -> Response {
    if let Some(token) = request.headers().get("authorization") {
        if let Ok(token_str) = token.to_str() {
            if validate_token(&db, token_str).await {
                return next.run(request).await;
            }
        }
    }
    Response::builder()
        .status(StatusCode::UNAUTHORIZED)
        .body(axum::body::Body::from("Unauthorized"))
        .unwrap()
}

let app = Router::new()
    .nest("/api/products", products_router())
    .layer(middleware::from_fn_with_state(auth_middleware))
    .layer(TraceLayer::new_for_http());

Middleware order matters. Layers closest to the handler run last. If you apply logging after auth, auth requests log as 401s. If you apply auth after logging, all requests get logged.

For a trading platform, we often need custom middleware for rate limiting or request context propagation:

rust
use axum::middleware::Next;
use std::sync::Arc;

#[derive(Clone)]
struct RateLimiter {
    max_requests: usize,
}

async fn rate_limit_middleware(
    State(limiter): State<Arc<RateLimiter>>,
    request: Request,
    next: Next,
) -> Response {
    // Check rate limit
    if is_rate_limited(&limiter) {
        return Response::builder()
            .status(StatusCode::TOO_MANY_REQUESTS)
            .body(axum::body::Body::from("Rate limited"))
            .unwrap();
    }
    next.run(request).await
}

Tower also ships with pre-built layers like TimeoutLayer, ConcurrencyLimitLayer, and BufferLayer. Use them to protect your service from slow clients and cascading failures.

Common mistakes

  • Blocking in handlers: Never call a synchronous, blocking function in an async handler. Use tokio::task::spawn_blocking if you must, but prefer async libraries. Blocking ties up the async runtime and degrades throughput.
  • Forgetting to handle extractor failures: If an extractor fails (bad JSON, missing path segment), Axum returns a 400 or 422. Make sure your error messages are informative for debugging, but don't leak internal details to clients.
  • Creating a new database pool per handler: Use State to share a single pool across all handlers. Creating pools repeatedly kills performance and exhausts system resources.
  • Middleware order confusion: Remember that layers execute in reverse order. The outermost layer runs first on the request, but last on the response. Draw a diagram if you're unsure.
  • Panicking in handlers: Unwrap errors and expect results in production handlers and you'll crash the thread. Always return a proper error response instead.

Key takeaways

  • Use Router::new() with .nest() to organize routes by domain; keep the main app router readable and modular.
  • Extractors are compile-time type-safe: Path, Query, Json, and State handle parsing and validation automatically.
  • Return any type implementing IntoResponse—tuples, status codes, and custom structs compose naturally.
  • Tower middleware layers wrap handlers; remember that layers execute in reverse order and timing matters for auth, logging, and rate limiting.
  • Share resources via State, never recreate pools or connections in hot paths.
  • Test extractors and middleware independently to catch errors early and reduce debugging burden.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles