Building Robust and Performant REST APIs with Axum, Actix Web, and Diesel
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Introduction
In the rapidly evolving landscape of web development, the demand for highly performant, reliable, and maintainable APIs is ever-increasing. Whether you're building a backend for a modern web application, a mobile service, or a microservice architecture, the choice of technology stack significantly impacts your project's success. Rust, with its unparalleled focus on performance, memory safety, and concurrency, has emerged as a compelling choice for backend development. This article delves into how we can leverage two of Rust's most popular — and often debated — web frameworks, Axum and Actix Web, alongside the powerful ORM (Object-Relational Mapper) Diesel, to construct REST APIs that are not only blazingly fast but also boast exceptional type safety and maintainability.
The practical significance of this combination is profound. Performance-critical applications benefit from Rust's near-zero overheads and compile-time guarantees, minimizing runtime errors and maximizing throughput. Type safety, enforced by Diesel, translates into fewer bugs related to database interactions, as data inconsistencies are caught at compile time rather than exploding in production. Furthermore, the robust ecosystem surrounding these libraries provides developers with the tools necessary to build complex, scalable systems with confidence. We will explore the core concepts behind each of these technologies, understand their individual strengths, and then demonstrate how they elegantly interoperate to form a cohesive and powerful backend solution.
Understanding the Core Components
Before diving into implementation, let's establish a clear understanding of the key technologies we'll be using: Axum, Actix Web, and Diesel.
Axum
Axum is a web application framework built on top of the Tokio asynchronous runtime and the Hyper HTTP library. It's developed by the Tokio community and leverages a "macro-free" design, opting for Rust's powerful type system for routing and middleware. Axum's core philosophy revolves around composability and adherence to standard Rust traits, making it feel very idiomatic for Rustaceans. Its key features include:
- Type-safe Routing: Routes are defined as functions that accept extractors, which parse request data and inject dependencies in a type-safe manner. This reduces the likelihood of runtime errors due to incorrect data types.
- Minimalistic and Composable: Axum encourages building applications from smaller, composable units. Middleware, for instance, can be applied to individual routes or groups of routes with ease.
- Tokio Ecosystem Integration: Being part of the Tokio ecosystem, Axum benefits from Tokio's robust async runtime, excellent concurrency primitives, and rich tooling.
Actix Web
Actix Web is a powerful, pragmatic, and extremely fast web framework for Rust. It's known for its actor-based concurrency model, which allows for highly concurrent applications with excellent performance characteristics. While an actor model might sound complex, Actix Web provides a high-level API that simplifies its use. Its distinguishing features are:
- Extreme Performance: Actix Web consistently ranks among the fastest web frameworks in various benchmarks. This is due to its efficient actor model and optimized internal architecture.
- Batteries Included: Actix Web provides a comprehensive set of features out-of-the-box, including routing, middleware, sessions, websockets, and a powerful testing utility.
- Actor-based Concurrency (Abstracted): While built on an actor model, developers primarily interact with
web::Service
andweb::Data
components, which abstract away much of the underlying complexity, focusing on handling HTTP requests concurrently.
Diesel
Diesel is a powerful and type-safe ORM and query builder for Rust. It aims to provide a safe and efficient way to interact with relational databases. Diesel's primary strength lies in its compile-time guarantees regarding database schema and query validity.
- Type Safety at Compile Time: Diesel generates Rust types that directly correspond to your database schema. This means that if you try to query a column that doesn't exist, or insert a value with an incorrect type, your code will fail to compile, preventing many common runtime errors.
- Powerful Query Builder: Diesel provides an expressive domain-specific language (DSL) for constructing complex SQL queries in a type-safe and idiomatic Rust manner.
- Migration System: Diesel includes a robust migration system that helps manage database schema evolution over time.
- Support for Multiple Databases: Diesel supports PostgreSQL, MySQL, and SQLite.
Why combine these?
The beauty of this combination lies in leveraging each component's strengths. Both Axum and Actix Web excel at handling HTTP requests and routing, with Axum offering more explicit type-safety through extractors and Actix Web providing raw performance and a feature-rich ecosystem. Diesel, on the other hand, provides the crucial layer of type-safe interaction with the database, abstracting away raw SQL while maintaining performance. This separation of concerns leads to maintainable code, where web logic is distinct from data access logic, and both are backed by Rust's strong type system.
Building a Basic REST API: Users and Posts
Let's illustrate how to build a simple REST API for managing users and their posts. We'll implement basic CRUD operations for both. We'll start by defining our database schema, then set up the Diesel ORM, and finally integrate it with both Axum and Actix Web.
Database Setup with Diesel
First, we need to set up a database (e.g., PostgreSQL). We'll use Diesel CLI to manage migrations.
1. Install Diesel CLI:
cargo install diesel_cli --no-default-features --features postgres
(Replace postgres
with mysql
or sqlite
if using a different database).
2. Initialize Diesel:
Create a .env
file with your database URL:
DATABASE_URL=postgres://user:password@localhost/your_database_name
Then, initialize Diesel in your project:
diesel setup
This creates a migrations
directory.
3. Create Migrations:
Define our users
and posts
tables.
diesel migration generate create_users
Edit the generated up.sql
file:
-- migrations/timestamp_create_users/up.sql CREATE TABLE users ( id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE );
diesel migration generate create_posts
Edit the generated up.sql
file:
-- migrations/timestamp_create_posts/up.sql CREATE TABLE posts ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), title VARCHAR(255) NOT NULL, body TEXT NOT NULL, published BOOLEAN NOT NULL DEFAULT FALSE );
4. Run Migrations:
diesel migration run
5. Generate Diesel Schema and Models:
Add diesel
and dotenvy
to your Cargo.toml
:
[dependencies] diesel = { version = "2.1.0", features = ["postgres", "r2d2", "chrono"] } # Also add Axum/Actix Web and serde, anyhow, etc. later
Run diesel print-schema
and copy the output into src/schema.rs
or use diesel infer-schema > src/schema.rs
. This generates the Rust representation of your tables.
// src/schema.rs // This file is generated by Diesel CLI. diesel::table! { posts (id) { id -> Int4, user_id -> Int4, title -> Varchar, body -> Text, published -> Bool, } } diesel::table! { users (id) { id -> Int4, username -> Varchar, email -> Varchar, } } diesel::joinable!(posts -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( posts, users, );
Next, define your Rust models and DTOs in src/models.rs
:
// src/models.rs use diesel::prelude::*; use serde::{Deserialize, Serialize}; use crate::schema::{users, posts}; #[derive(Queryable, Selectable, Debug, Serialize)] #[diesel(table_name = users)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct User { pub id: i32, pub username: String, pub email: String, } #[derive(Insertable, Deserialize)] #[diesel(table_name = users)] pub struct NewUser { pub username: String, pub email: String, } #[derive(Queryable, Selectable, Debug, Serialize)] #[diesel(belongs_to(User))] #[diesel(table_name = posts)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Post { pub id: i32, pub user_id: i32, pub title: String, pub body: String, pub published: bool, } #[derive(Insertable, Deserialize)] #[diesel(table_name = posts)] pub struct NewPost { pub user_id: i32, pub title: String, pub body: String, pub published: Option<bool>, // Optional, defaults to false in DB } #[derive(Deserialize, Serialize)] pub struct UpdatePost { pub title: Option<String>, pub body: Option<String>, pub published: Option<bool>, }
Database Connection Pooling
For performance, we'll use r2d2
for connection pooling.
// src/db_config.rs use diesel::pg::PgConnection; use diesel::r2d2::{ConnectionManager, Pool}; use std::env; pub type PgPool = Pool<ConnectionManager<PgConnection>>; pub fn establish_connection_pool() -> PgPool { dotenvy::dotenv().ok(); let database_url = env::var("DATABASE_URL") .expect("DATABASE_URL must be set"); let manager = ConnectionManager::<PgConnection>::new(database_url); Pool::builder() .max_size(10) // Maximum number of connections in the pool .build(manager) .expect("Failed to create pool.") }
Implementing REST Endpoints with Axum
Now, let's build the API endpoints using Axum.
1. Cargo.toml
for Axum:
[dependencies] axum = { version = "0.7.5", features = ["macros"] } tokio = { version = "1.37.0", features = ["full"] } serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.117" diesel = { version = "2.1.0", features = ["postgres", "r2d2", "chrono"] } r2d2 = "0.8.10" anyhow = "1.0.83" dotenvy = "0.15.7"
2. Axum API handlers in src/handlers_axum.rs
:
// src/handlers_axum.rs use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Json}, routing::{get, post, patch, delete}, Router, }; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; use serde_json::json; use crate::models::{User, NewUser, Post, NewPost, UpdatePost}; use crate::schema::{users, posts}; use crate::db_config::PgPool; pub type DbConnection = diesel::r2d2::PooledConnection<ConnectionManager<PgConnection>>; // Helper function to get a connection from the pool async fn get_conn(pool: &PgPool) -> Result<DbConnection, (StatusCode, String)> { pool.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get DB connection: {}", e)) }) } // User Handlers pub async fn create_user( State(pool): State<PgPool>, Json(new_user): Json<NewUser>, ) -> Result<Json<User>, (StatusCode, String)> { let mut conn = get_conn(&pool).await?; let user = diesel::insert_into(users::table) .values(&new_user) .get_result::<User>(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create user: {}", e)))?; Ok(Json(user)) } pub async fn get_all_users( State(pool): State<PgPool>, ) -> Result<Json<Vec<User>>, (StatusCode, String)> { let mut conn = get_conn(&pool).await?; let users_list = users::table .load::<User>(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch users: {}", e)))?; Ok(Json(users_list)) } pub async fn get_user_by_id( State(pool): State<PgPool>, Path(user_id): Path<i32>, ) -> Result<Json<User>, (StatusCode, String)> { let mut conn = get_conn(&pool).await?; let user = users::table .find(user_id) .first::<User>(&mut conn) .map_err(|e| match e { diesel::result::Error::NotFound => (StatusCode::NOT_FOUND, "User not found".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get user: {}", e)), })?; Ok(Json(user)) } // Post Handlers pub async fn create_post( State(pool): State<PgPool>, Json(new_post): Json<NewPost>, ) -> Result<Json<Post>, (StatusCode, String)> { let mut conn = get_conn(&pool).await?; let post = diesel::insert_into(posts::table) .values(&new_post) .get_result::<Post>(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create post: {}", e)))?; Ok(Json(post)) } pub async fn get_posts_by_user( State(pool): State<PgPool>, Path(user_id): Path<i32>, ) -> Result<Json<Vec<Post>>, (StatusCode, String)> { let mut conn = get_conn(&pool).await?; let user_posts = Post::belonging_to(&users::table.find(user_id).first::<User>(&mut conn) .map_err(|e| match e { diesel::result::Error::NotFound => (StatusCode::NOT_FOUND, "User not found for posts".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch user for posts: {}", e)), })?) .load::<Post>(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch posts: {}", e)))?; Ok(Json(user_posts)) } pub async fn update_post( State(pool): State<PgPool>, Path(post_id): Path<i32>, Json(update_data): Json<UpdatePost>, ) -> Result<Json<Post>, (StatusCode, String)> { let mut conn = get_conn(&pool).await?; let updated_post: Post = diesel::update(posts::table.find(post_id)) .set(( update_data.title.map(|t| posts::title.eq(t)), update_data.body.map(|b| posts::body.eq(b)), update_data.published.map(|p| posts::published.eq(p)), )) .get_result::<Post>(&mut conn) .map_err(|e| match e { diesel::result::Error::NotFound => (StatusCode::NOT_FOUND, "Post not found".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update post: {}", e)), })?; Ok(Json(updated_post)) } pub async fn delete_post( State(pool): State<PgPool>, Path(post_id): Path<i32>, ) -> Result<StatusCode, (StatusCode, String)> { let mut conn = get_conn(&pool).await?; let num_deleted = diesel::delete(posts::table.find(post_id)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete post: {}", e)))?; if num_deleted == 0 { Err((StatusCode::NOT_FOUND, "Post not found".to_string())) } else { Ok(StatusCode::NO_CONTENT) } }
3. Main function in src/main.rs
(Axum version):
// src/main.rs (Axum version) mod schema; mod models; mod db_config; mod handlers_axum; use axum::Router; use axum::routing::get; use crate::db_config::establish_connection_pool; use crate::handlers_axum::{ create_user, get_all_users, get_user_by_id, create_post, get_posts_by_user, update_post, delete_post }; #[tokio::main] async fn main() { let pool = establish_connection_pool(); let app = Router::new() .route("/users", get(get_all_users).post(create_user)) .route("/users/:user_id", get(get_user_by_id)) .route("/users/:user_id/posts", get(get_posts_by_user)) .route("/posts", post(create_post)) .route("/posts/:post_id", patch(update_post).delete(delete_post)) .with_state(pool); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("Axum server listening on http://0.0.0.0:3000"); axum::serve(listener, app).await.unwrap(); }
Implementing REST Endpoints with Actix Web
Now, let's build the same API endpoints using Actix Web.
1. Cargo.toml
for Actix Web:
[dependencies] actix-web = "4.9.0" actix-rt = "2.10.0" # For the `main` macro serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.117" diesel = { version = "2.1.0", features = ["postgres", "r2d2", "chrono"] } r2d2 = "0.8.10" anyhow = "1.0.83" dotenvy = "0.15.7"
2. Actix Web API handlers in src/handlers_actix.rs
:
// src/handlers_actix.rs use actix_web::{web, HttpResponse, Responder}; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; use serde_json::json; use crate::models::{User, NewUser, Post, NewPost, UpdatePost}; use crate::schema::{users, posts}; use crate::db_config::PgPool; pub type DbConnection = diesel::r2d2::PooledConnection<ConnectionManager<PgConnection>>; // Helper function to get a connection from the pool async fn get_conn_actix(pool: web::Data<PgPool>) -> Result<DbConnection, HttpResponse> { web::block(move || pool.get()) .await .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to get DB connection: {}", e)))? .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to get DB connection: {}", e))) } // User Handlers pub async fn create_user_actix( pool: web::Data<PgPool>, new_user: web::Json<NewUser>, ) -> impl Responder { let mut conn = match get_conn_actix(pool).await { Ok(c) => c, Err(e) => return e, }; let user = web::block(move || { diesel::insert_into(users::table) .values(&new_user.into_inner()) .get_result::<User>(&mut conn) }) .await .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to create user: {}", e)))? .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to create user: {}", e)))?; HttpResponse::Ok().json(user) } pub async fn get_all_users_actix( pool: web::Data<PgPool>, ) -> impl Responder { let mut conn = match get_conn_actix(pool).await { Ok(c) => c, Err(e) => return e, }; let users_list = web::block(move || { users::table .load::<User>(&mut conn) }) .await .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to fetch users: {}", e)))? .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to fetch users: {}", e)))?; HttpResponse::Ok().json(users_list) } pub async fn get_user_by_id_actix( pool: web::Data<PgPool>, path: web::Path<i32>, ) -> impl Responder { let user_id = path.into_inner(); let mut conn = match get_conn_actix(pool).await { Ok(c) => c, Err(e) => return e, }; let user = web::block(move || { users::table .find(user_id) .first::<User>(&mut conn) }) .await .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to get user: {}", e)))? .map_err(|e| match e { diesel::result::Error::NotFound => HttpResponse::NotFound().body("User not found"), _ => HttpResponse::InternalServerError().body(format!("Failed to get user: {}", e)), })?; HttpResponse::Ok().json(user) } // Post Handlers pub async fn create_post_actix( pool: web::Data<PgPool>, new_post: web::Json<NewPost>, ) -> impl Responder { let mut conn = match get_conn_actix(pool).await { Ok(c) => c, Err(e) => return e, }; let post = web::block(move || { diesel::insert_into(posts::table) .values(&new_post.into_inner()) .get_result::<Post>(&mut conn) }) .await .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to create post: {}", e)))? .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to create post: {}", e)))?; HttpResponse::Ok().json(post) } pub async fn get_posts_by_user_actix( pool: web::Data<PgPool>, path: web::Path<i32>, ) -> impl Responder { let user_id = path.into_inner(); let mut conn = match get_conn_actix(pool).await { Ok(c) => c, Err(e) => return e, }; let user_posts = web::block(move || { let user_found = users::table.find(user_id).first::<User>(&mut conn)?; Post::belonging_to(&user_found) .load::<Post>(&mut conn) }) .await .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to fetch posts: {}", e)))? .map_err(|e| match e { diesel::result::Error::NotFound => HttpResponse::NotFound().body("User not found for posts"), _ => HttpResponse::InternalServerError().body(format!("Failed to fetch posts: {}", e)), })?; HttpResponse::Ok().json(user_posts) } pub async fn update_post_actix( pool: web::Data<PgPool>, path: web::Path<i32>, update_data: web::Json<UpdatePost>, ) -> impl Responder { let post_id = path.into_inner(); let mut conn = match get_conn_actix(pool).await { Ok(c) => c, Err(e) => return e, }; let updated_post: Post = web::block(move || { diesel::update(posts::table.find(post_id)) .set(( update_data.title.map(|t| posts::title.eq(t)), update_data.body.map(|b| posts::body.eq(b)), update_data.published.map(|p| posts::published.eq(p)), )) .get_result::<Post>(&mut conn) }) .await .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to update post: {}", e)))? .map_err(|e| match e { diesel::result::Error::NotFound => HttpResponse::NotFound().body("Post not found"), _ => HttpResponse::InternalServerError().body(format!("Failed to update post: {}", e)), })?; HttpResponse::Ok().json(updated_post) } pub async fn delete_post_actix( pool: web::Data<PgPool>, path: web::Path<i32>, ) -> impl Responder { let post_id = path.into_inner(); let mut conn = match get_conn_actix(pool).await { Ok(c) => c, Err(e) => return e, }; let num_deleted = web::block(move || { diesel::delete(posts::table.find(post_id)) .execute(&mut conn) }) .await .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to delete post: {}", e)))? .map_err(|e| HttpResponse::InternalServerError().body(format!("Failed to delete post: {}", e)))?; if num_deleted == 0 { HttpResponse::NotFound().body("Post not found") } else { HttpResponse::NoContent().finish() } }
3. Main function in src/main.rs
(Actix Web version):
// src/main.rs (Actix Web version) mod schema; mod models; mod db_config; mod handlers_actix; use actix_web::{web, App, HttpServer}; use crate::db_config::establish_connection_pool; use crate::handlers_actix::{ create_user_actix, get_all_users_actix, get_user_by_id_actix, create_post_actix, get_posts_by_user_actix, update_post_actix, delete_post_actix }; #[actix_web::main] async fn main() -> std::io::Result<()> { let pool = establish_connection_pool(); let pool_data = web::Data::new(pool); // Wrap pool in web::Data println!("Actix Web server listening on http://0.0.0.0:3000"); HttpServer::new(move || { App::new() .app_data(pool_data.clone()) // Pass shared data to routes .service( web::resource("/users") .route(web::get().to(get_all_users_actix)) .route(web::post().to(create_user_actix)) ) .service(web::resource("/users/{user_id}").route(web::get().to(get_user_by_id_actix))) .service(web::resource("/users/{user_id}/posts").route(web::get().to(get_posts_by_user_actix))) .service(web::resource("/posts").route(web::post().to(create_post_actix))) .service( web::resource("/posts/{post_id}") .route(web::patch().to(update_post_actix)) .route(web::delete().to(delete_post_actix)) ) }) .bind(("0.0.0.0", 3000))? .run() .await }
Note: To switch between Axum and Actix Web, comment out the main
function of one and uncomment the other, and adjust Cargo.toml
dependencies accordingly.
Application Scenario and Benefits
This example demonstrates a typical REST API backend for a blogging platform or content management system. Key benefits observed:
- Performance: Both Axum and Actix Web, backed by Tokio's async runtime, deliver exceptional raw performance for handling concurrent HTTP requests. Database interactions are efficiently managed via
r2d2
connection pooling, preventing bottlenecks. - Type Safety: Diesel's compile-time checks ensure that our API handlers interact with the database correctly.
NewUser
,User
,NewPost
,Post
, andUpdatePost
structs directly map to database operations and JSON payloads, minimizing runtime type errors. Any mismatch in schema or query structure results in a compilation error, catching bugs early. - Maintainability: The clear separation between web framework logic (routing, request handling) and database interaction logic (Diesel queries) makes the codebase organized and easier to maintain. Handlers are focused on orchestrating data flow, while Diesel handles the intricacies of SQL.
- Scalability: Rust's memory efficiency and the async nature of Axum/Actix Web allow these services to handle a large number of concurrent connections with minimal resource consumption, making them ideal for high-traffic applications.
- Robustness: Rust's ownership system and borrow checker eliminate common classes of bugs like null pointer dereferences and data races, leading to more robust and reliable services.
Conclusion
Building REST APIs with Rust using Axum or Actix Web alongside Diesel offers a powerful combination for developers seeking performance, type safety, and reliability. This stack empowers you to create backend services that are not only blazingly fast but also resilient and a pleasure to maintain. By catching a majority of potential errors at compile time, Rust, Axum/Actix Web, and Diesel collectively raise the bar for what you can expect from your backend infrastructure. This synergistic approach truly unlocks the potential of Rust for web development, pushing the boundaries of what's achievable in terms of both speed and correctness.