Structurer un projet web Rust (une archi Axum propre qui scale)
Arrête de tout mettre dans main.rs. Une structure de projet Axum éprouvée, de 5 à 50 routes : couches, state, erreurs, tests.
La plupart des tutos Axum s’arrêtent à 3 routes dans main.rs. Un vrai service a plus de 30 routes, des jobs en tâche de fond et 3 devs. Voici la structure que j’utilise en production.
Le problème du main.rs fourre-tout
- Conflits de merge à chaque PR
- Aucune place définie pour la logique métier face à la glue HTTP
- Impossible de tester sans lancer HTTP
- Erreurs gérées de 5 façons différentes
La structure
src/
main.rs # 60 lignes max : config, tracing, pool, router, serve
config.rs # env typée
state.rs # AppState { db, redis, http_client, config }
error.rs # AppError
extractors.rs # AuthUser, RequestId (extracteurs custom)
routes/ # HTTP uniquement : parse l'entrée, appelle le service, mappe la sortie
mod.rs
tasks.rs
users.rs
services/ # logique métier : quasi pure, testable
tasks.rs
repos/ # SQL uniquement (optionnel tant que tu n'en as pas besoin)
tasks.rs
models/
middleware/
observability.rs
tests/
api_tasks.rs # black-box via axum-test ou reqwest
migrations/
Règle générale : routes/ connaît HTTP, services/ non. repos/ connaît SQL, services/ n’écrit pas de SQL brut si tu les sépares.
main.rs reste bête
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cfg = config::load()?;
observability::init(&cfg)?;
let db = db::connect(&cfg.database_url).await?;
let state = AppState::new(db, cfg.clone());
let app = routes::router(state);
let listener = tokio::net::TcpListener::bind(cfg.addr).await?;
tracing::info!("listening on {}", cfg.addr);
axum::serve(listener, app).await?;
Ok(())
}
Si ton main.rs dépasse 100 lignes, tu mélanges les responsabilités.
Les services se testent
// services/tasks.rs
pub async fn create_task(db: &PgPool, title: &str) -> Result<Task, AppError> {
if title.trim().is_empty() {
return Err(AppError::BadRequest("title is empty".into()));
}
repos::tasks::insert(db, title).await.map_err(Into::into)
}
// routes/tasks.rs: glue fine
pub async fn create_task(
State(s): State<AppState>,
Json(input): Json<CreateTask>,
) -> Result<impl IntoResponse, AppError> {
let task = services::tasks::create_task(&s.db, &input.title).await?;
Ok((StatusCode::CREATED, Json(task)))
}
Maintenant tu peux tester services::tasks::create_task avec juste une BDD, sans HTTP.
Erreurs : un seul enum
Un seul AppError pour toute l’API (voir le guide Axum + Postgres). Mapping : NotFound → 404, BadRequest → 400, Unauthorized → 401, Db → 500 (loggue tout, renvoie du générique).
Ne crée pas TaskError, UserError, AuthError sans vraie raison. Tu écrirais juste 3x des impls IntoResponse.
Config : typée, échec au démarrage
#[derive(Clone)]
pub struct Config {
pub addr: SocketAddr,
pub database_url: String,
}
pub fn load() -> anyhow::Result<Config> {
dotenvy::dotenv().ok();
Ok(Config {
addr: std::env::var("ADDR").unwrap_or("0.0.0.0:8080".into()).parse()?,
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL missing")?,
})
}
Échoue au boot, pas sur la première requête à 3h du matin.
Stratégie de tests
cargo test services::: tests unitaires rapides, avecsqlx::testet un vrai Postgres en Dockertests/api_*.rs: aller-retour HTTP complet, une BDD de test par run, truncate entre les tests- Ne mocke pas
PgPool. Utilise un vrai conteneur Postgres. Les mocks mentent sur le SQL.
Quand passer aux workspaces ?
- 1 service, moins de 50 routes : une seule crate (ce guide)
- 2 à 3 services qui partagent des modèles : workspace Cargo +
crates/common - Microservices : un repo par déployable, partagés via des crates versionnées, pas des hacks de chemins
Commence en monolithe modulaire. Extrais quand les cycles de déploiement divergent, pas avant.
Le template complet avec cette structure, le Dockerfile et la CI GitHub est dans ma newsletter gratuite.