Building Modular Web APIs with Axum in Rust
Daniel Hayes
Full-Stack Engineer · Leapcell

Introduction
In the ever-evolving landscape of backend development, building robust, scalable, and maintainable web APIs is paramount. Rust, with its focus on performance, safety, and concurrency, has emerged as a compelling choice for this domain. However, the raw power of Rust often comes with a steeper learning curve, especially when orchestrating complex application logic. This is where modern web frameworks like Axum shine. Axum, built atop the powerful Tokio runtime and the versatile Tower ecosystem, provides a high-level, ergonomic way to construct web services in Rust. It embraces the concept of modularity, allowing developers to organize their API endpoints, manage shared state efficiently, and integrate powerful middleware in a coherent manner. This article will guide you through the process of building a modular web API with Axum, demonstrating how to effectively handle routing, share application state, and leverage the extensibility offered by Tower services.
Understanding the Core Concepts
Before diving into implementation, let's clarify some fundamental concepts central to building APIs with Axum:
- Axum: A web application framework for Rust. It's built on top of
tokio
(asynchronous runtime) andhyper
(HTTP library), and heavily leverages thetower
ecosystem for middleware and service composition. - Routing: The mechanism by which incoming HTTP requests are directed to the appropriate handler functions based on their URL path and HTTP method. Axum provides a declarative and type-safe way to define routes.
- State Management: In web applications, it's often necessary to share data (e.g., database connections, configuration, caches) across different request handlers. Axum offers robust mechanisms for managing application-wide and request-scoped state.
- Tower Services: Tower is a library of modular, reusable components for building robust network applications. In Axum, handlers are essentially
Tower.Service
implementations, and middleware areTower.Layer
s that wrap services. This architecture promotes composition and reusability. - Middleware: Functions or services that sit between the server and the handler, allowing you to preprocess requests or postprocess responses. Common uses include authentication, logging, error handling, and rate limiting.
Building a Modular Web API
Let's construct a simple API for managing a list of users, demonstrating modular routing, state sharing, and the application of Tower services.
Project Setup
First, create a new Rust project:
cargo new axum_modular_api cd axum_modular_api
Add the necessary dependencies to your Cargo.toml
:
[dependencies] axum = { version = "0.7", features = ["macros"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" tracing-subscriber = "0.3"
Defining Our Application State
For our user management API, we'll need a way to store users. A simple Vec<User>
wrapped in an Arc<Mutex<...>>
is a good starting point for in-memory state.
Create a src/models.rs
file:
// src/models.rs use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { pub id: u32, pub name: String, pub email: String, } pub type AppState = Arc<Mutex<Vec<User>>>; pub fn initialize_state() -> AppState { Arc::new(Mutex::new(vec![ User { id: 1, name: "Alice".to_string(), email: "alice@example.com".to_string() }, User { id: 2, name: "Bob".to_string(), email: "bob@example.com".to_string() }, ])) }
Modular Routing
We'll organize our routes into separate modules for better maintainability. Create a src/routes/mod.rs
and src/routes/users.rs
file.
src/routes/users.rs
: This module will contain all user-related endpoints.
// src/routes/users.rs use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use serde_json::json; use crate::models::{AppState, User}; pub fn users_router() -> Router<AppState> { Router::new() .route("/", get(list_users).post(create_user)) .route("/:id", get(get_user).put(update_user).delete(delete_user)) } async fn list_users(State(state): State<AppState>) -> Json<Vec<User>> { let users = state.lock().unwrap(); Json(users.clone()) } async fn get_user(State(state): State<AppState>, Path(id): Path<u32>) -> Result<Json<User>, StatusCode> { let users = state.lock().unwrap(); if let Some(user) = users.iter().find(|u| u.id == id) { Ok(Json(user.clone())) } else { Err(StatusCode::NOT_FOUND) } } async fn create_user(State(state): State<AppState>, Json(mut new_user): Json<User>) -> impl IntoResponse { let mut users = state.lock().unwrap(); let next_id = users.iter().map(|u| u.id).max().unwrap_or(0) + 1; new_user.id = next_id; users.push(new_user.clone()); (StatusCode::CREATED, Json(new_user)) } async fn update_user( State(state): State<AppState>, Path(id): Path<u32>, Json(updated_user): Json<User>, ) -> impl IntoResponse { let mut users = state.lock().unwrap(); if let Some(user) = users.iter_mut().find(|u| u.id == id) { user.name = updated_user.name; user.email = updated_user.email; (StatusCode::OK, Json(user.clone())) } else { (StatusCode::NOT_FOUND, Json(json!({"message": "User not found"}))) } } async fn delete_user(State(state): State<AppState>, Path(id): Path<u32>) -> StatusCode { let mut users = state.lock().unwrap(); let initial_len = users.len(); users.retain(|u| u.id != id); if users.len() < initial_len { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } }
src/routes/mod.rs
: This module will re-export our sub-routers and potentially contain common routes.
// src/routes/mod.rs pub mod users;
Composing the Main Application
Now, let's bring it all together in src/main.rs
. We'll initialize our application state, compose the router, and add some basic logging using tracing
.
// src/main.rs mod models; mod routes; use axum::{ routing::get, Router, }; use tower_http::trace::{self, TraceLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use std::time::Duration; #[tokio::main] async fn main() { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "axum_modular_api=debug,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); let app_state = models::initialize_state(); // Build our application with a route let app = Router::new() .route("/", get(|| async { "Hello, Modular Axum API!" })) // Mount the users router under the /users path .nest("/users", routes::users::users_router()) .with_state(app_state) // Add Tower services (middleware) .layer( TraceLayer::new_for_http() .make_span_with(trace::DefaultMakeSpan::new().include_headers(true)) .on_request(trace::DefaultOnRequest::new().level(tracing::Level::INFO)) .on_response(trace::DefaultOnResponse::new().level(tracing::Level::INFO).latency_300_ms(Duration::from_millis(300))), ); 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(); }
Running the API
You can run this API using cargo run
.
cargo run
Then, you can interact with it using tools like curl
:
- GET all users:
curl http://localhost:3000/users
- GET a user by ID:
curl http://localhost:3000/users/1
- POST a new user:
curl -X POST -H "Content-Type: application/json" -d '{"name": "Charlie", "email": "charlie@example.com"}' http://localhost:3000/users
- PUT to update a user:
curl -X PUT -H "Content-Type: application/json" -d '{"name": "Alice Smith", "email": "alice.smith@example.com"}' http://localhost:3000/users/1
- DELETE a user:
curl -X DELETE http://localhost:3000/users/2
Explanation of Key Features
-
Modular Routing:
- The
users_router()
function insrc/routes/users.rs
returns anaxum::Router
. This router encapsulates all user-related logic. - In
main.rs
, we use.nest("/users", routes::users::users_router())
to mount this sub-router under the/users
path. This creates a clear hierarchy and keeps your mainmain.rs
file cleaner. - The
Router<AppState>
type signature ensures that theAppState
is consistently passed down to all routes within that router.
- The
-
State Sharing:
- We define
AppState
asArc<Mutex<Vec<User>>>
.Arc
allows multiple owners of the state, andMutex
handles safe concurrent access. - The
with_state(app_state)
method on the mainRouter
injects ourAppState
into the application. - In handler functions,
State(state): State<AppState>
is used as an extractor to retrieve the shared state. This is type-safe and idiomatic Axum.
- We define
-
Tower Services and Middleware:
- We used
tower_http::trace::TraceLayer
to add request/response logging. This is a powerful example of a TowerLayer
. - The
.layer(...)
method on theRouter
applies this middleware to all routes defined afterwith_state
and before.layer
. If applied beforewith_state
, the middleware wouldn't have access to the state. - Tower services can be chained, allowing you to compose complex middleware pipelines for authentication, rate limiting, CORS, compression, etc., without cluttering your core business logic.
- We used
Application Scenarios
This modular approach is beneficial for:
- Large APIs: As your API grows, separating concerns into distinct routing modules prevents your
main.rs
from becoming a monolithic file. - Team Collaboration: Different teams or developers can work on separate API modules without significant merge conflicts.
- Maintainability: Changes to one area of the API are less likely to impact unrelated parts.
- Testability: Individual routers and their handlers can be tested in isolation.
Conclusion
Building modular web APIs with Axum in Rust provides a powerful and organized way to manage complexity, ensuring scalability and maintainability. By effectively leveraging Axum's declarative routing, robust state management, and the tower
ecosystem's composable services, developers can construct high-performance, type-safe, and easily extensible backend systems. This approach not only streamlines development but also fosters a clean architecture, making your Rust web applications a pleasure to build and maintain.