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.

La couche service, concrètement#

C’est la règle qui tient tout le reste : un handler parse l’entrée, appelle une fonction, mappe la sortie. Rien d’autre. La logique métier vit dans des fonctions qui ne savent pas qu’HTTP existe.

// services/tasks.rs — aucune mention d'Axum ici
pub async fn complete(pool: &PgPool, user: Uuid, id: Uuid) -> Result<Task, AppError> {
    let task = sqlx::query_as::<_, Task>(
        "UPDATE tasks SET done = true WHERE id = $1 AND user_id = $2 \
         RETURNING id, title, done, created_at",
    )
    .bind(id)
    .bind(user)
    .fetch_optional(pool)
    .await?
    .ok_or(AppError::NotFound)?;

    Ok(task)
}
// routes/tasks.rs — HTTP uniquement
async fn complete(
    State(state): State<AppState>,
    user: AuthUser,
    Path(id): Path<Uuid>,
) -> Result<Json<Task>, AppError> {
    Ok(Json(services::tasks::complete(&state.db, user.id, id).await?))
}

Le bénéfice se voit le jour où la même opération doit tourner depuis un job de fond, une commande CLI ou un consumer de file. Tu appelles la fonction. Sans cette séparation, il faut fabriquer une fausse requête HTTP, ou dupliquer.

Le user_id dans le WHERE est le deuxième point non négociable. Une requête qui filtre sur le seul id laisse n’importe quel compte lire les lignes des autres en devinant un identifiant.

Ce que cette structure ne règle pas#

Elle a un coût, et le nier ne rend service à personne.

Sur un service de cinq routes, ces sept fichiers sont de la cérémonie. Trois handlers dans main.rs sont plus lisibles, et le jour où ça grossit, la découpe prend une heure. Ne structure pas en prévision d’une croissance hypothétique.

Elle ne remplace pas non plus un découpage par domaine. Passé une trentaine de routes couvrant plusieurs sujets métier, le découpage horizontal (routes/, services/, models/) fatigue : toucher une fonctionnalité oblige à ouvrir trois répertoires. Un découpage vertical (tasks/{routes,service,model}.rs) regroupe ce qui change ensemble. Les deux se valent, le mauvais choix est de mélanger les deux à moitié.

Enfin, elle ne dit rien des transactions traversant plusieurs services. Le jour où deux opérations doivent être atomiques, la fonction doit accepter un &mut PgConnection plutôt qu’un &PgPool, sinon chaque appel prend sa propre connexion et l’atomicité disparaît.

Stratégie de tests#

  • cargo test services::: tests unitaires rapides, avec sqlx::test et un vrai Postgres en Docker
  • tests/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.
  • Les dépendances de test et le reste de la stack : les meilleures crates Rust pour une API.

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

Dès que tu ajoutes de l’auth, middleware/ et handlers/auth.rs se remplissent vite : le découpage est montré en pratique dans l’auth JWT dans Axum comme il faut.

Commence en monolithe modulaire. Extrais quand les cycles de déploiement divergent, pas avant.

Cette structure existe en vrai : le template Axum l’applique à la lettre, Dockerfile et tests compris. git clone, cargo test, et tu pars de là.