VOOZH about

URL: https://dev.to/syeedmdtalha/build-in-memory-crud-api-in-axum-rust-1mj3

⇱ Build In-Memory CRUD API in Axum (Rust) - DEV Community


If you're learning Rust and want to build APIs, Axum is one of the best frameworks to start with.

In this tutorial, we'll build a simple CRUD API (Create, Read, Update, Delete) using:

  • ✅ Axum
  • ✅ In-memory storage (no database)
  • ✅ Beginner-friendly concepts

What We Will Build

A simple User API with these endpoints:

Method Route Description
POST /users Create a user
GET /users Get all users
GET /user/:id Get a single user
PUT /user/:id Update a user
DELETE /user/:id Delete a user

Step 1: Setup Dependencies

Add this to your Cargo.toml:

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Step 2: Define Our Data

#[derive(Debug, Serialize, Deserialize, Clone)]
struct User {
 id: u32,
 name: String,
 email: String,
}

#[derive(Deserialize)]
struct CreateUser {
 name: String,
 email: String,
}
  • User → the stored data structure
  • CreateUser → the incoming request body

Step 3: App State (In-Memory Database)

type AppState = Arc<Mutex<Vec<User>>>;

We use:

  • Vec<User> → to store users
  • Mutex → for safe concurrent access
  • Arc → to share state across threads

Step 4: Create User (POST)

async fn create_user(
 State(state): State<AppState>,
 Json(body): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
 let mut users = state.lock().unwrap();
 let new_id = users.last().map_or(1, |u| u.id + 1);
 let new_user = User {
 id: new_id,
 name: body.name,
 email: body.email,
 };
 users.push(new_user.clone());
 (StatusCode::CREATED, Json(new_user))
}

Step 5: Get All Users (GET)

async fn user_list(State(state): State<AppState>) -> Json<Vec<User>> {
 let users = state.lock().unwrap();
 Json(users.clone())
}

Step 6: Get Single User

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)
 }
}

Step 7: Update User

async fn update_user(
 State(state): State<AppState>,
 Path(id): Path<u32>,
 Json(body): Json<CreateUser>,
) -> Result<Json<User>, StatusCode> {
 let mut users = state.lock().unwrap();

 if let Some(user) = users.iter_mut().find(|u| u.id == id) {
 user.name = body.name;
 user.email = body.email;
 Ok(Json(user.clone()))
 } else {
 Err(StatusCode::NOT_FOUND)
 }
}

Step 8: Delete User

async fn delete_user(
 State(state): State<AppState>,
 Path(id): Path<u32>,
) -> StatusCode {
 let mut users = state.lock().unwrap();

 if let Some(pos) = users.iter().position(|u| u.id == id) {
 users.remove(pos);
 StatusCode::NO_CONTENT
 } else {
 StatusCode::NOT_FOUND
 }
}

Step 9: Setup Router & Main

#[tokio::main]
async fn main() {
 let state: AppState = Arc::new(Mutex::new(vec![]));

 let app = Router::new()
 .route("/users", get(user_list).post(create_user))
 .route(
 "/user/:id",
 get(get_user)
 .put(update_user)
 .delete(delete_user),
 )
 .with_state(state);

 let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
 .await
 .unwrap();

 println!("Server running on http://localhost:3000");
 axum::serve(listener, app).await.unwrap();
}

Full Code (Mutex Version)

use axum::{
 extract::{State, Path},
 http::StatusCode,
 routing::{get, post, put, delete},
 Json,
 Router,
};

use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

#[derive(Debug, Serialize, Deserialize, Clone)]
struct User {
 id: u32,
 name: String,
 email: String,
}

#[derive(Deserialize)]
struct CreateUser {
 name: String,
 email: String,
}

type AppState = Arc<Mutex<Vec<User>>>;

async fn create_user(
 State(state): State<AppState>,
 Json(body): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
 let mut users = state.lock().unwrap();
 let new_user = User {
 id: users.len() as u32 + 1,
 name: body.name,
 email: body.email,
 };
 users.push(new_user.clone());
 (StatusCode::CREATED, Json(new_user))
}

async fn user_list(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 update_user(
 State(state): State<AppState>,
 Path(id): Path<u32>,
 Json(body): Json<CreateUser>,
) -> Result<Json<User>, StatusCode> {
 let mut users = state.lock().unwrap();
 if let Some(user) = users.iter_mut().find(|u| u.id == id) {
 user.name = body.name;
 user.email = body.email;
 Ok(Json(user.clone()))
 } else {
 Err(StatusCode::NOT_FOUND)
 }
}

async fn delete_user(
 State(state): State<AppState>,
 Path(id): Path<u32>,
) -> StatusCode {
 let mut users = state.lock().unwrap();
 if let Some(pos) = users.iter().position(|u| u.id == id) {
 users.remove(pos);
 StatusCode::NO_CONTENT
 } else {
 StatusCode::NOT_FOUND
 }
}

#[tokio::main]
async fn main() {
 let state: AppState = Arc::new(Mutex::new(vec![]));

 let app = Router::new()
 .route("/users", post(create_user))
 .route("/users", get(user_list))
 .route("/user/:id", put(update_user))
 .route("/user/:id", delete(delete_user))
 .route("/user/:id", get(get_user))
 .with_state(state);

 let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
 println!("Server running on http://localhost:3000");
 axum::serve(listener, app).await.unwrap();
}

Bonus: Async RwLock Version

For better read performance (multiple concurrent reads), use tokio::sync::RwLock instead of Mutex:

use axum::{
 extract::State,
 http::StatusCode,
 routing::{get, post, put, delete},
 extract::{Path, Json},
 Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
 id: u32,
 name: String,
 email: String,
}

#[derive(Debug, Deserialize)]
struct CreateUser {
 name: String,
 email: String,
}

type AppState = Arc<RwLock<Vec<User>>>;

async fn create_user(
 State(state): State<AppState>,
 Json(body): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
 let mut users = state.write().await;
 let new_id = users.last().map_or(1, |u| u.id + 1);
 let new_user = User {
 id: new_id,
 name: body.name,
 email: body.email,
 };
 users.push(new_user.clone());
 (StatusCode::CREATED, Json(new_user))
}

async fn user_list(State(state): State<AppState>) -> Json<Vec<User>> {
 let users = state.read().await;
 Json(users.clone())
}

async fn get_user(
 State(state): State<AppState>,
 Path(id): Path<u32>,
) -> Result<Json<User>, StatusCode> {
 let users = state.read().await;
 if let Some(user) = users.iter().find(|u| u.id == id) {
 Ok(Json(user.clone()))
 } else {
 Err(StatusCode::NOT_FOUND)
 }
}

async fn delete_user(
 State(state): State<AppState>,
 Path(id): Path<u32>,
) -> StatusCode {
 let mut users = state.write().await;
 if let Some(pos) = users.iter().position(|u| u.id == id) {
 users.remove(pos);
 StatusCode::NO_CONTENT
 } else {
 StatusCode::NOT_FOUND
 }
}

async fn update_user(
 State(state): State<AppState>,
 Path(id): Path<u32>,
 Json(body): Json<User>,
) -> Result<Json<User>, StatusCode> {
 let mut users = state.write().await;
 if let Some(user) = users.iter_mut().find(|u| u.id == id) {
 user.name = body.name;
 user.email = body.email;
 Ok(Json(user.clone()))
 } else {
 Err(StatusCode::NOT_FOUND)
 }
}

#[tokio::main]
async fn main() {
 let state: AppState = Arc::new(RwLock::new(vec![]));

 let app = Router::new()
 .route("/users", get(user_list))
 .route("/users", post(create_user))
 .route("/user/{id}", get(get_user))
 .route("/user/{id}", delete(delete_user))
 .route("/user/{id}", put(update_user))
 .with_state(state);

 let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
 println!("Server running on http://localhost:3000");
 axum::serve(listener, app).await.unwrap();
}

Mutex vs RwLock: Use Mutex for simplicity. Use RwLock when you have many reads and few writes — it allows multiple readers simultaneously.


How to Test

Use Postman or curl to test your endpoints.

Create a user:

POST /users
Content-Type: application/json

{
 "name": "John",
 "email": "john@example.com"
}

Get all users:

GET /users

Get a single user:

GET /user/1

Update a user:

PUT /user/1
Content-Type: application/json

{
 "name": "John Updated",
 "email": "john_updated@example.com"
}

Delete a user:

DELETE /user/1

Key Concepts You Learned

  • Axum routing — how to define and chain HTTP routes
  • State management — sharing data across handlers with Arc<Mutex<T>>
  • JSON handling — extracting request bodies and returning JSON responses
  • Path parameters — reading dynamic values from the URL
  • CRUD operations — implementing all four basic data operations

What's Next?

Now that you have the basics down, you can level up by:

  • Replacing in-memory storage with PostgreSQL (using sqlx)
  • Adding input validation (using validator)
  • Adding authentication (JWT tokens)
  • Switching to tokio::sync::RwLock for better concurrency

Happy coding!