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, 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. - 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à.