← Tous les articles • 08/02/2026 • 2 min de lecture

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.

#axum#architecture#backend

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

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

Quand passer aux workspaces ?

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.


Ça t'a plu ? Récupère la checklist prod.

Template Axum + patterns Postgres + checklist p99. Gratuit, un email par semaine.

Je la veux →

🚀 Gratuit : la checklist Rust Backend Prod

Rejoins les devs backend. Reçois mon template Axum, ma checklist latence p99 et mes questions d'entretien. Un email pratique par semaine.

Zéro spam. Désinscription en un clic. Ce que tu vas recevoir →