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.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
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
| File | What goes in it |
|---|---|
src/models.rs | Post, NewPost, Store and the Db type |
src/error.rs | ApiError and its IntoResponse |
src/handlers.rs | check and the five handlers |
src/main.rs | the 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:
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:
mod models;Run it:
cargo runerror[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 accessibleFourteen 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:
use models::{Db, NewPost, Post};Run again:
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:
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:
error[E0616]: field `next_id` of struct `Store` is private
--> src/main.rs:62:11
|
62 | store.next_id += 1;
| ^^^^^^^ private fieldThirteen 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:
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:
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:
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:
cargo buildwarning: 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 warningsIt builds, with seven unused-import warnings: main does not need them any more. Keep only Router, get and Db. The final src/main.rs:
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
cargo runIn a second terminal:
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/9HTTP/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
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, DbWhen 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;inmain.rsmakessrc/models.rspart of the programuse models::{...}brings names into scope;E0425 ... exists but is inaccessiblemeans it is in another module- Everything in a module is private:
pubon structs, enums, types and functions (E0603 ... is private) - A
pub structkeeps its fields private until each getspub(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.