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

Rust Web Server with Axum 0.8: Project Structure with Modules

October 28, 2026

Episode 6 of the Rust Backend series, where we build the backend of a blog step by step. After episode 5 our main.rs holds everything: the data types, the ApiError type, a check function and five handlers, about 115 lines. Today we split it into modules: models.rs, error.rs, handlers.rs, and a short main.rs for the router. On the way we meet the three errors everyone hits with Rust modules: a name that cannot be found, an item that is private, and a field that is private. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episode 5 (axum 0.8.9, tokio, serde, serde_json, thiserror). No new crates today.

1. The plan

FileWhat goes in it
src/models.rsPost, NewPost, Store and the Db type
src/error.rsApiError and its IntoResponse
src/handlers.rscheck and the five handlers
src/main.rsthe mod lines and the router

2. models.rs

Cut the four data types out of main.rs, and remove the imports that only they need (serde, BTreeMap, Arc and Mutex). Create src/models.rs, write those imports and paste:

rust
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>>;

3. mod models;

A file is not compiled just because it exists in src. main.rs has to declare it. Add this as the first line of main.rs:

rust
mod models;

Run it:

bash
cargo run
text
error[E0425]: cannot find type `NewPost` in this scope
  --> src/main.rs:34:16
   |
34 | fn check(new: &NewPost) -> Result<(), ApiError> {
   |                ^^^^^^^ not found in this scope
   |
note: struct `crate::models::NewPost` exists but is inaccessible
  --> src/models.rs:13:1
   |
13 | struct NewPost {
   | ^^^^^^^^^^^^^^ not accessible

Fourteen errors like this one. Look at the note: the compiler found NewPost in models, but main cannot use it from there yet.

4. use models::{...}

Bring the names into main.rs, below the other use lines:

rust
use models::{Db, NewPost, Post};

Run again:

text
error[E0603]: type alias `Db` is private
  --> src/main.rs:8:14
   |
 8 | use models::{Db, NewPost, Post};
   |              ^^ private type alias
   |
note: the type alias `Db` is defined here
  --> src/models.rs:24:1
   |
24 | type Db = Arc<Mutex<Store>>;
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Three errors: Db, NewPost and Post are private. Everything in a module is private unless we mark it pub.

5. pub on the items

In models.rs, write pub before each struct and before type Db:

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

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

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

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

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

Run again:

text
error[E0616]: field `next_id` of struct `Store` is private
  --> src/main.rs:62:11
   |
62 |     store.next_id += 1;
   |           ^^^^^^^ private field

Thirteen errors. A pub struct does not make its fields pub.

6. pub on the fields

Fields get pub one by one. That is on purpose: a struct can keep its insides hidden and offer methods instead. Here the handlers need the fields, so they all become pub. The final src/models.rs:

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

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

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

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

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

cargo run now builds and prints Listening on http://localhost:3000.

7. error.rs

Stop the server. Cut ApiError and its impl IntoResponse out of main.rs, create src/error.rs, write its imports, paste, and make the enum pub (its variants are public automatically). The final src/error.rs:

rust
use axum::extract::rejection::JsonRejection;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("post {0} not found")]
    NotFound(u32),
    #[error("title must not be empty")]
    EmptyTitle,
    #[error(transparent)]
    BadJson(#[from] JsonRejection),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let status = match &self {
            ApiError::NotFound(_) => StatusCode::NOT_FOUND,
            ApiError::EmptyTitle => StatusCode::BAD_REQUEST,
            ApiError::BadJson(rejection) => rejection.status(),
        };
        let message = match &self {
            ApiError::BadJson(rejection) => rejection.body_text(),
            other => other.to_string(),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

In main.rs, add mod error; at the top and use error::ApiError; with the other use lines.

8. handlers.rs and crate::

Cut check and the five handlers out of main.rs into a new src/handlers.rs. Its imports start with crate::, which means: start at the top of our project (the crate root, main.rs). Each handler becomes pub so the router can reach it; check is only used inside this file, so it stays private. The final src/handlers.rs:

rust
use axum::extract::{rejection::JsonRejection, Path, State};
use axum::{http::StatusCode, Json};

use crate::error::ApiError;
use crate::models::{Db, NewPost, Post};

fn check(new: &NewPost) -> Result<(), ApiError> {
    if new.title.trim().is_empty() {
        return Err(ApiError::EmptyTitle);
    }
    Ok(())
}

pub async fn create_post(
    State(db): State<Db>,
    payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<(StatusCode, Json<Post>), ApiError> {
    let Json(new) = payload?;
    check(&new)?;
    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());
    Ok((StatusCode::CREATED, Json(post)))
}

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

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

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

pub async fn delete_post(State(db): State<Db>, Path(id): Path<u32>) -> Result<StatusCode, ApiError> {
    let mut store = db.lock().unwrap();
    store.posts.remove(&id).ok_or(ApiError::NotFound(id))?;
    Ok(StatusCode::NO_CONTENT)
}

9. A short main.rs

Add mod handlers; and call the handlers through their module, handlers::get_post and so on. Then build:

bash
cargo build
text
warning: unused imports: `Json`, `Path`, `State`, and `http::StatusCode`
 --> src/main.rs:5:22
  |
5 | use axum::{extract::{Path, State}, http::StatusCode, routing::get, Json, Router};
  |                      ^^^^  ^^^^^   ^^^^^^^^^^^^^^^^                ^^^^
...
warning: `blog_api` (bin "blog_api") generated 7 warnings

It builds, with seven unused-import warnings: main does not need them any more. Keep only Router, get and Db. The final src/main.rs:

rust
mod error;
mod handlers;
mod models;

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

#[tokio::main]
async fn main() {
    let db = Db::default();
    let app = Router::new()
        .route("/posts", get(handlers::list_posts).post(handlers::create_post))
        .route(
            "/posts/{id}",
            get(handlers::get_post).put(handlers::update_post).delete(handlers::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();
}

10. Same API as before

bash
cargo run

In a second terminal:

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

{"id":1,"title":"Hello","body":"First post"}

HTTP/1.1 200 OK
content-type: application/json
content-length: 44

{"id":1,"title":"Hello","body":"First post"}

HTTP/1.1 404 Not Found
content-type: application/json
content-length: 28

{"error":"post 9 not found"}

Nothing changed on the outside. (date: lines are left out above.)

The project now

text
blog_api/
โ”œโ”€โ”€ Cargo.toml
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ error.rs      ApiError + IntoResponse
    โ”œโ”€โ”€ handlers.rs   check + the five handlers
    โ”œโ”€โ”€ main.rs       mod lines + router
    โ””โ”€โ”€ models.rs     Post, NewPost, Store, Db

When a file grows, it can become a folder later (src/handlers/mod.rs or src/handlers.rs next to a src/handlers/ folder).

Recap

  • mod models; in main.rs makes src/models.rs part of the program
  • use models::{...} brings names into scope; E0425 ... exists but is inaccessible means it is in another module
  • Everything in a module is private: pub on structs, enums, types and functions (E0603 ... is private)
  • A pub struct keeps its fields private until each gets pub (E0616 field ... is private)
  • crate:: starts a path at the top of the project

Next episode: configuration. The port and settings come from a .env file, and we see what happens when a value cannot be parsed. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam

Rust Web Server with Axum 0.8: Project Structure with Modules - Mahmudul Alam