M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 3 of 42

Rust Web Server with Axum 0.8: In-Memory CRUD with State

October 26, 2026

Episode 3 of the Rust Backend series, where we build the backend of a blog step by step. In episode 2 our API learned JSON, but every post was made up. Today we keep real posts in memory and build all of CRUD: create, read, update and delete. We share one store between all handlers with Arc<Mutex<...>> and with_state, fix a real compile error, and answer 201, 200, 204 and 404. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episodes 1 and 2 (axum 0.8, tokio, serde with the derive feature). No new crates today.

1. What CRUD means

Each action is an HTTP method on /posts:

text
Create  ->  POST   /posts
Read    ->  GET    /posts       and  GET /posts/{id}
Update  ->  PUT    /posts/{id}
Delete  ->  DELETE /posts/{id}

2. A store for the posts

A Store holds a BTreeMap from id to post, and the next id. A BTreeMap keeps its keys in order, so listing the posts always comes back sorted by id.

Every request may run on a different thread. The Mutex lets only one handler touch the store at a time, and the Arc lets all handlers share the same store:

rust
type Db = Arc<Mutex<Store>>;

In main we create an empty database with Db::default() and give it to the router with .with_state(db). A handler gets it back with the State<Db> extractor. The Json extractor reads the request body, so it must be the last argument.

get_post looks up the id. ok_or turns a missing post into 404 Not Found, and the ? returns it early.

Replace src/main.rs with:

rust
use axum::{extract::{Path, State}, http::StatusCode, routing::{get, post}, Json, Router};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

#[derive(Serialize)]
struct Post {
    id: u32,
    title: String,
    body: String,
}

#[derive(Deserialize)]
struct NewPost {
    title: String,
    body: String,
}

#[derive(Default)]
struct Store {
    posts: BTreeMap<u32, Post>,
    next_id: u32,
}

type Db = Arc<Mutex<Store>>;

#[tokio::main]
async fn main() {
    let db = Db::default();
    let app = Router::new()
        .route("/posts", post(create_post))
        .route("/posts/{id}", get(get_post))
        .with_state(db);

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

async fn create_post(State(db): State<Db>, Json(new): Json<NewPost>) -> (StatusCode, Json<Post>) {
    let mut store = db.lock().unwrap();
    store.next_id += 1;
    let post = Post { id: store.next_id, title: new.title, body: new.body };
    store.posts.insert(post.id, post.clone());
    (StatusCode::CREATED, Json(post))
}

async fn get_post(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Post>, StatusCode> {
    let store = db.lock().unwrap();
    let post = store.posts.get(&id).ok_or(StatusCode::NOT_FOUND)?;
    Ok(Json(post.clone()))
}

3. Error: no method named clone found for struct Post

cargo run fails with two errors:

text
error[E0599]: no method named `clone` found for struct `Post` in the current scope
  --> src/main.rs:44:38
   |
 7 | struct Post {
   | ----------- method `clone` not found for this struct
...
44 |     store.posts.insert(post.id, post.clone());
   |                                      ^^^^^ method not found in `Post`

error[E0308]: mismatched types
  --> src/main.rs:51:13
   |
51 |     Ok(Json(post.clone()))
   |        ---- ^^^^^^^^^^^^ expected `Post`, found `&Post`
...
note: `Post` does not implement `Clone`, so `&Post` was cloned instead
...
help: consider annotating `Post` with `#[derive(Clone)]`
   |
 7 + #[derive(Clone)]
 8 | struct Post {

error: could not compile `blog_api` (bin "blog_api") due to 2 previous errors

The first one says our struct cannot be copied yet. The second one even shows the fix. We add Clone to the derive:

rust
#[derive(Serialize, Clone)]
struct Post {
    id: u32,
    title: String,
    body: String,
}

4. Test create and read with curl

Start the server with cargo run, then in a second terminal create a post. It gets id 1:

bash
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hello","body":"My first post"}'
text
HTTP/1.1 201 Created
content-type: application/json
content-length: 47

{"id":1,"title":"Hello","body":"My first post"}

Read it back. The server remembered it:

bash
curl -i localhost:3000/posts/1
text
HTTP/1.1 200 OK
content-type: application/json
content-length: 47

{"id":1,"title":"Hello","body":"My first post"}

A post that does not exist:

bash
curl -i localhost:3000/posts/9
text
HTTP/1.1 404 Not Found
content-length: 0

5. List, update and delete

Stop the server (Ctrl + C) and add three handlers at the end of src/main.rs:

  • list_posts locks the store and clones every post into a vector.
  • update_post takes the id and the new title and body. get_mut gives us a mutable reference, so we change the post in place.
  • delete_post removes the id. If a post was removed we answer 204 No Content, if not 404 Not Found.
rust
async fn list_posts(State(db): State<Db>) -> Json<Vec<Post>> {
    let store = db.lock().unwrap();
    Json(store.posts.values().cloned().collect())
}

async fn update_post(
    State(db): State<Db>,
    Path(id): Path<u32>,
    Json(new): Json<NewPost>,
) -> Result<Json<Post>, StatusCode> {
    let mut store = db.lock().unwrap();
    let post = store.posts.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
    post.title = new.title;
    post.body = new.body;
    Ok(Json(post.clone()))
}

async fn delete_post(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode {
    let mut store = db.lock().unwrap();
    match store.posts.remove(&id) {
        Some(_) => StatusCode::NO_CONTENT,
        None => StatusCode::NOT_FOUND,
    }
}

6. One path, many methods

/posts gets get and post; /posts/{id} gets get, put and delete. Since post is now a method call (.post(...)), the routing import only needs get:

rust
use axum::{extract::{Path, State}, http::StatusCode, routing::get, Json, Router};
rust
    let app = Router::new()
        .route("/posts", get(list_posts).post(create_post))
        .route("/posts/{id}", get(get_post).put(update_post).delete(delete_post))
        .with_state(db);

7. Test everything with curl

Run the server again and list all posts:

bash
curl -i localhost:3000/posts
text
HTTP/1.1 200 OK
content-type: application/json
content-length: 2

[]

An empty list! The posts lived only in memory, so they were gone when the server stopped. A database will fix that later in this series.

Create a post again (the same POST as above, it gets id 1), then update it. -X PUT picks the method:

bash
curl -i localhost:3000/posts/1 -X PUT -H 'content-type: application/json' -d '{"title":"Hello again","body":"Edited"}'
text
HTTP/1.1 200 OK
content-type: application/json
content-length: 46

{"id":1,"title":"Hello again","body":"Edited"}

List again: our edited post is in the list.

text
[{"id":1,"title":"Hello again","body":"Edited"}]

Delete it, then delete it again:

bash
curl -i localhost:3000/posts/1 -X DELETE
curl -i localhost:3000/posts/1 -X DELETE
text
HTTP/1.1 204 No Content

HTTP/1.1 404 Not Found
content-length: 0

204 No Content means it worked and there is no body. The second time the post is already gone, so 404.

8. The final code

The complete src/main.rs from the end of the video:

rust
use axum::{extract::{Path, State}, http::StatusCode, routing::get, Json, Router};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

#[derive(Serialize, Clone)]
struct Post {
    id: u32,
    title: String,
    body: String,
}

#[derive(Deserialize)]
struct NewPost {
    title: String,
    body: String,
}

#[derive(Default)]
struct Store {
    posts: BTreeMap<u32, Post>,
    next_id: u32,
}

type Db = Arc<Mutex<Store>>;

#[tokio::main]
async fn main() {
    let db = Db::default();
    let app = Router::new()
        .route("/posts", get(list_posts).post(create_post))
        .route("/posts/{id}", get(get_post).put(update_post).delete(delete_post))
        .with_state(db);

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

async fn create_post(State(db): State<Db>, Json(new): Json<NewPost>) -> (StatusCode, Json<Post>) {
    let mut store = db.lock().unwrap();
    store.next_id += 1;
    let post = Post { id: store.next_id, title: new.title, body: new.body };
    store.posts.insert(post.id, post.clone());
    (StatusCode::CREATED, Json(post))
}

async fn get_post(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Post>, StatusCode> {
    let store = db.lock().unwrap();
    let post = store.posts.get(&id).ok_or(StatusCode::NOT_FOUND)?;
    Ok(Json(post.clone()))
}

async fn list_posts(State(db): State<Db>) -> Json<Vec<Post>> {
    let store = db.lock().unwrap();
    Json(store.posts.values().cloned().collect())
}

async fn update_post(
    State(db): State<Db>,
    Path(id): Path<u32>,
    Json(new): Json<NewPost>,
) -> Result<Json<Post>, StatusCode> {
    let mut store = db.lock().unwrap();
    let post = store.posts.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
    post.title = new.title;
    post.body = new.body;
    Ok(Json(post.clone()))
}

async fn delete_post(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode {
    let mut store = db.lock().unwrap();
    match store.posts.remove(&id) {
        Some(_) => StatusCode::NO_CONTENT,
        None => StatusCode::NOT_FOUND,
    }
}

Recap

  • Shared state: Arc<Mutex<Store>>, given to the router with with_state
  • The State<Db> extractor (the Json extractor comes last)
  • E0599 / E0308 from a missing Clone, fixed with #[derive(Serialize, Clone)]
  • All five CRUD routes: POST, GET, GET by id, PUT, DELETE, answering 201, 200, 204 and 404
  • In-memory data is gone after a restart

Next episode: error handling with Result, the ? operator and our own error type. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam