Project Structure
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. axum imposes no project
layout: a Router is a value that any module can build and return. That freedom is pleasant in a 100-line service and
a liability in a 10,000-line one, so this page shows the structure Bookshelf uses and the reasoning behind it. The
general architecture discussion — monolith or services, layering, migrations — lives in the
Architecture section; this page covers only the Rust and axum side.
The Bookshelf layout
Bookshelf is a Cargo workspace (see Cargo & Workspaces) with the domain crate from the Rust Reference and one web crate:
shelf/
├── Cargo.toml # [workspace] members, shared [workspace.dependencies] and [workspace.lints]
├── crates/
│ ├── shelf-core/ # domain: Isbn, Book, Author, Loan, Catalog trait -- no axum, no sqlx
│ ├── shelf/ # the CLI from the Rust Reference
│ └── bookshelf-web/
│ ├── Cargo.toml
│ ├── migrations/ # sqlx migrations
│ ├── templates/ # askama templates
│ ├── static/ # CSS, JS, images served by ServeDir
│ └── src/
│ ├── main.rs # read config, init tracing, connect pools, serve with graceful shutdown
│ ├── lib.rs # pub fn app(state) -> Router, so tests can build the router
│ ├── app.rs # assemble routers and the middleware stack
│ ├── config.rs # typed Settings
│ ├── state.rs # AppState and its FromRef substates
│ ├── error.rs # AppError + IntoResponse
│ ├── routes/ # JSON API: books.rs, authors.rs, reviews.rs, loans.rs
│ ├── web/ # server-rendered pages: catalogue.rs, account.rs
│ ├── auth/ # sessions, JWT, OAuth2, CurrentUser and role extractors
│ ├── db/ # repositories (sqlx) implementing the ports
│ └── services/ # use cases: lending rules, review moderation
└── tests/ # integration tests that call bookshelf_web::app()
Source: Cargo: workspaces
Three rules keep the dependencies pointing one way:
-
The domain crate knows nothing about HTTP or SQL.
shelf-corehas no axum, sqlx or tokio dependency, so its types and rules are unit-tested in microseconds and reused by the CLI. -
Handlers are thin. A handler extracts input, calls one service function, and maps the result to a response. Business rules ("a member may not borrow more than five books") live in
services, where they can be tested without a router. -
Infrastructure is behind traits. Services depend on a
BookRepotrait, anddbprovides the sqlx implementation. Tests substitute an in-memory one.
Splitting main.rs from lib.rs matters for testing: integration tests in tests/ can only call public items of a
library target, so pub fn app(state: AppState) -> Router lives in the library and main.rs only wires the real
dependencies (see Testing).
Routes close to handlers, or a central router?
There are two common ways to organise routes.
Central router. app.rs lists every path and points at handler functions in other modules. You see the whole URL
space in one place, but every new endpoint touches two files.
Routes next to handlers. Each module exposes a fn router() -> Router<AppState> that registers its own paths, and
app.rs only nests or merges them. This is the approach of the official routes-and-handlers-close-together example,
and the one Bookshelf uses:
use axum::Router;
#[derive(Clone, Default)]
pub struct AppState {}
mod books {
use axum::{Json, Router, extract::Path, routing::get};
use super::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/", get(list)).route("/{isbn}", get(show))
}
async fn list() -> Json<Vec<String>> {
Json(vec!["Dune".to_owned()])
}
async fn show(Path(isbn): Path<String>) -> String {
format!("book {isbn}")
}
}
mod authors {
use axum::{Router, routing::get};
use super::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/", get(|| async { "authors" }))
}
}
pub fn app(state: AppState) -> Router {
let api = Router::new().nest("/books", books::router()).nest("/authors", authors::router());
Router::new().nest("/api/v1", api).with_state(state)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app(AppState::default())).await.unwrap();
}
Each sub-router is typed Router<AppState> — "a router that still needs an AppState`" — and the state is supplied
once, at the top, with `with_state. Handlers stay private to their module. nest and merge are covered on
Routing Composition, and the Router<S> type parameter on
State & Dependency Injection.
Layered architecture: handler → service → repository
The service layer is plain Rust: functions or structs that take their dependencies as parameters. The repository is a
trait — the port in hexagonal-architecture terms — and the sqlx implementation is an adapter. To keep a trait
object in state, its async methods must return boxed futures, because async fn in traits (stable since Rust 1.75) is
not yet dyn-compatible:
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::get,
};
use futures::future::BoxFuture;
use shelf_core::{Author, Book, Isbn};
// ---- port (would live in services/ or shelf-core) ----
pub trait BookRepo: Send + Sync {
fn find(&self, isbn: Isbn) -> BoxFuture<'_, Result<Option<Book>, RepoError>>;
}
#[derive(Debug, thiserror::Error)]
#[error("storage unavailable")]
pub struct RepoError;
// ---- adapter for tests and demos (the sqlx one lives in db/) ----
#[derive(Default)]
pub struct InMemoryBooks(RwLock<HashMap<Isbn, Book>>);
impl BookRepo for InMemoryBooks {
fn find(&self, isbn: Isbn) -> BoxFuture<'_, Result<Option<Book>, RepoError>> {
Box::pin(async move { Ok(self.0.read().map_err(|_| RepoError)?.get(&isbn).cloned()) })
}
}
// ---- service: business rules, no HTTP types ----
pub async fn book_details(repo: &dyn BookRepo, isbn: Isbn) -> Result<Option<Book>, RepoError> {
repo.find(isbn).await
}
// ---- web layer ----
#[derive(Clone)]
pub struct AppState {
pub books: Arc<dyn BookRepo>,
}
async fn show(State(state): State<AppState>, Path(isbn): Path<String>) -> Result<Json<Book>, StatusCode> {
let isbn = Isbn::parse(&isbn).map_err(|_| StatusCode::BAD_REQUEST)?;
match book_details(state.books.as_ref(), isbn).await {
Ok(Some(book)) => Ok(Json(book)),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE),
}
}
pub fn app(state: AppState) -> Router {
Router::new().route("/api/v1/books/{isbn}", get(show)).with_state(state)
}
#[tokio::main]
async fn main() {
let repo = InMemoryBooks::default();
let dune = Isbn::parse("9780441013593").unwrap();
repo.0.write().unwrap().insert(dune.clone(), Book::new(dune, "Dune", vec![Author::new("Frank Herbert")], 1965));
let state = AppState { books: Arc::new(repo) };
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app(state)).await.unwrap();
}
-
BookRepo: Send + Syncis required because theArc<dyn BookRepo>is shared by every tokio worker thread; without it the handler’s future is notSendand axum rejects the handler. -
futures::future::BoxFuture<'a, T>isPin<Box<dyn Future<Output = T> + Send + 'a>>. Theasync-traitcrate generates the same boxing from an#[async_trait]attribute, if you prefer writingasync fnin the trait. -
The service function takes
&dyn BookRepo, notAppState, so it can be called from a CLI, a background job or a test without building any web state.
The alternative is generics: struct AppState<R: BookRepo> { books: Arc<R> } with a native async fn trait. That
avoids the allocation and dynamic dispatch, at the cost of a type parameter that spreads through every handler and
router type (Router<AppState<PgBookRepo>>). The official dependency-injection example shows both; Bookshelf uses
trait objects because the boxing cost is negligible next to a database round trip.
Where state, errors and configuration live
-
state.rsdefinesAppState— the database pool, the Redis manager, repositories, the HTTP client, settings — with#[derive(Clone, FromRef)]so handlers can extract only the part they need (see State & Dependency Injection). -
error.rsdefines oneAppErrorenum and itsIntoResponseimplementation; every handler returnsResult<T, AppError>(see Error Handling). -
config.rsdefines a typedSettingsstruct loaded once inmainand validated before the server starts (see Configuration). -
main.rsis the composition root: it is the only place that creates concrete adapters (PgPool,PgBookRepo) and injects them intoAppState.
When the web crate grows large, the next step is to move services and the repository traits into their own crate
(shelf-app), so that compile times stay manageable — Cargo compiles crates in parallel and only recompiles the ones
that changed. The Architectural Patterns pages discuss
hexagonal, clean and modular-monolith architectures in general.
Where the books are outdated
Yoon (Beginning Axum) keeps the whole application in a handful of modules with handlers calling SeaORM directly,
which is fine for a tutorial but couples HTTP, persistence and rules. It also predates the stabilisation of async fn
in traits (1.75) in its wording about async traits; native async fn works for generic use, while trait objects still
need boxed futures or the async-trait crate.