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()
Diagram of the Bookshelf crates and modules. The bookshelf-web crate contains main.rs, which depends on app.rs, which builds the Router and layers and depends on state.rs; app.rs also depends on the routes, web and auth modules. Routes and web handlers call the services module (use cases such as lend_book and add_review), auth depends on error.rs, and services depend on a BookRepo trait (the port) stored as Arc dyn BookRepo in the state. The db module, holding PgBookRepo with its sqlx queries, implements that trait. The trait and the error module depend on the separate shelf-core crate, which holds Isbn, Book, Author, Loan, LoanState, the Catalog trait and CatalogError, has no axum, sqlx or tokio dependency and is also used by the shelf CLI.

Three rules keep the dependencies pointing one way:

  1. The domain crate knows nothing about HTTP or SQL. shelf-core has no axum, sqlx or tokio dependency, so its types and rules are unit-tested in microseconds and reused by the CLI.

  2. 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.

  3. Infrastructure is behind traits. Services depend on a BookRepo trait, and db provides 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 + Sync is required because the Arc<dyn BookRepo> is shared by every tokio worker thread; without it the handler’s future is not Send and axum rejects the handler.

  • futures::future::BoxFuture<'a, T> is Pin<Box<dyn Future<Output = T> + Send + 'a>>. The async-trait crate generates the same boxing from an #[async_trait] attribute, if you prefer writing async fn in the trait.

  • The service function takes &dyn BookRepo, not AppState, 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.rs defines AppState — 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.rs defines one AppError enum and its IntoResponse implementation; every handler returns Result<T, AppError> (see Error Handling).

  • config.rs defines a typed Settings struct loaded once in main and validated before the server starts (see Configuration).

  • main.rs is the composition root: it is the only place that creates concrete adapters (PgPool, PgBookRepo) and injects them into AppState.

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.