datzero9

axum from Scratch: Building a JSON API

axum is an HTTP framework for Rust built on top of tokio, hyper, and tower. It's deliberately minimal: no macros required, no framework-specific traits to implement, just functions and the type system.

A minimal server

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(handler));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

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

Handlers are plain async functions. axum's magic is that it converts a huge variety of function signatures into valid handlers through the Handler trait — extractors go in, responses come out.

Extractors

Extractors implement the FromRequest or FromRequestParts trait. axum ships with a full suite:

use axum::{
    extract::{Path, Query, State},
    Json,
};
use serde::Deserialize;

#[derive(Deserialize)]
struct Params {
    page: Option<u32>,
}

async fn list_posts(
    State(db): State<DbPool>,
    Path(tag): Path<String>,
    Query(params): Query<Params>,
) -> Json<Vec<Post>> {
    let page = params.page.unwrap_or(1);
    let posts = db.posts_by_tag(&tag, page).await;
    Json(posts)
}

The order of extractors in the function signature doesn't matter (with one exception: Body-consuming extractors like Json<T> must come last).

Shared state

State is injected via State<T> extractor after being registered on the router:

#[derive(Clone)]
struct AppState {
    db: DbPool,
}

let state = AppState { db: pool };
let app = Router::new()
    .route("/posts", get(list_posts))
    .with_state(state);

T must be Clone + Send + Sync + 'static. Wrapping expensive resources in Arc is idiomatic.

Error handling

axum handlers return Result<T, E> where E: IntoResponse. Define a custom error type:

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

enum AppError {
    NotFound,
    Internal(anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
            AppError::Internal(e) => {
                tracing::error!("{e:#}");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
            }
        }
    }
}

Now your handlers can use ? freely:

async fn get_post(
    State(db): State<AppState>,
    Path(slug): Path<String>,
) -> Result<Json<Post>, AppError> {
    let post = db.post_by_slug(&slug).await?;
    Ok(Json(post))
}

Middleware with tower

axum's middleware story is built on tower's Layer trait. Adding tracing, compression, and rate limiting:

use tower_http::{
    compression::CompressionLayer,
    trace::TraceLayer,
};

let app = Router::new()
    .route("/posts", get(list_posts))
    .layer(TraceLayer::new_for_http())
    .layer(CompressionLayer::new());

Layers apply bottom-up (last added, first executed) — keep that in mind when ordering authentication and rate-limiting middleware.

What I like about axum

  • Zero magic. If it compiles, you understand what it does.
  • The extractor pattern scales well: test handlers by calling them directly with extracted types, no HTTP stack required.
  • tower interoperability means you inherit a rich ecosystem of middleware.

The tradeoff is that the type errors can be gnarly when you're learning. But once you've internalized extractors and IntoResponse, the model is remarkably consistent.