Retour au blog
Guide Technique d'Architecture · 10 min de lecture

Architecture Cargo workspace : comment structurer un backend Rust multi-services

Affane Daylami · Fondateur· 23 août 2026

Un backend multi-services en Rust pose vite la même question : un dépôt par service, ou un seul workspace Cargo ? Aurabase a tranché pour la seconde option. Onze services, une CLI et cinq bibliothèques partagées vivent dans un unique Cargo.toml racine, avec un seul Cargo.lock pour tout le monde. Voici comment ce workspace est construit, en le lisant ligne par ligne.

Ce n'est pas un exemple pédagogique inventé pour l'occasion. Chaque extrait de code ci-dessous vient du Cargo.toml racine d'Aurabase et des manifestes de ses crates, tels qu'ils existent aujourd'hui dans le dépôt — y compris deux écarts qu'on a trouvés en les relisant pour cet article, et qu'on documente tels quels plutôt que de les corriger en douce avant publication.

L'essentiel
  • Le workspace Cargo racine d'Aurabase déclare 18 membres explicites : 11 services, la CLI aura-cli, 5 libs partagées et le SDK aurabase-rs — sous resolver = "2" et un seul Cargo.lock.
  • [workspace.dependencies] centralise les versions partagées ; chaque crate hérite avec { workspace = true } plutôt que de fixer son propre numéro — sauf quand un crate déroge, silencieusement.
  • Un dossier sous services/ n'est pas automatiquement un membre du workspace : la liste members est explicite, pas un glob, précisément pour pouvoir exclure du code non-Rust.
  • [profile.release] s'applique une seule fois à tout le workspace — un choix comme panic = "unwind" vaut alors pour les onze services d'un coup.
#
Pourquoi un workspace

Un dépôt par service, ou un seul Cargo.lock ?

Un workspace Cargo regroupe plusieurs crates sous un seul Cargo.lock et un seul répertoire cible — la fonctionnalité même que le gestionnaire de paquets de Rust prévoit pour ce cas. Onze binaires Rust séparés, chacun dans son propre dépôt, semblent au premier abord plus indépendants. En pratique, ça veut dire onze Cargo.lock distincts, onze résolutions de versions qui peuvent diverger avec le temps, et aucune garantie que deux services compilent la même version d'axum ou de sqlx.

Un workspace Cargo résout ça au niveau du gestionnaire de paquets, pas de la discipline d'équipe. Tous les membres partagent un seul Cargo.lock à la racine : une dépendance commune est résolue une fois, à une version identique, pour tout le graphe. C'est aussi ce qui rend un refactor cross-service (changer une signature dans aura-core, par exemple) visible d'un seul cargo build --workspace, plutôt que découvert service par service en production. C'est un des choix qui distingue notre cœur 100 % Rust unifié d'un stack hétérogène assemblé service par service.

#
Anatomie

Le Cargo.toml racine : resolver et membres

Tout commence par la déclaration [workspace] et sa liste members. Chez Aurabase, cette liste est écrite à la main, groupée par rôle — services, outils, libs — plutôt que générée par un motif générique comme services/*.

Cargo.toml
TOML
[workspace]
resolver = "2"
members = [
# Services
"services/aura-gateway",
"services/aura-auth",
"services/aura-db",
# … 8 autres services (aura-realtime, aura-storage, aura-ai, …)
# Tools
"aura-cli",
# Libs
"libs/aura-core",
# … 4 autres libs
"aurabase-rs",
]

resolver = "2" n'est pas un détail cosmétique. Le resolver v2 de Cargo isole les fonctionnalités des build-dependencies et des dépendances spécifiques à une cible (target.'cfg(windows)', par exemple) du reste du graphe — elles ne fuitent plus dans le binaire final. Il unifie aussi les versions d'une même dépendance à travers tous les membres du workspace qui la partagent : un seul axum, une seule fois, pas onze résolutions indépendantes.

#
Héritage

workspace.package : une version, une édition, en principe partagées

[workspace.package] déclare une fois les champs communs — version, édition, auteurs, licence — que chaque crate peut hériter avec version.workspace = true au lieu de les recopier.

Cargo.toml (racine)
TOML
[workspace.package]
version = "0.1.1"
edition = "2021"
# services/aura-gateway/Cargo.toml
[package]
name = "aura-gateway"
version.workspace = true

Les onze services d'Aurabase suivent tous ce schéma. Deux crates dérogent, et c'est le premier des deux écarts trouvés en préparant cet article : aura-cli déclare version = "0.2.0" en dur, et la lib libs/aura-migrations déclare version = "0.1.0" — toutes deux différentes du 0.1.1 du workspace.

Ce que ça veut dire
version.workspace = true est optionnel, champ par champ, crate par crate. Rien n'empêche un crate de garder sa propre numérotation — volontairement (un outil publié séparément, par exemple) ou par oubli. Un audit de workspace doit vérifier ce champ crate par crate, pas supposer l'héritage.
#
Déduplication

workspace.dependencies : une source de vérité, sauf si un crate la contourne

[workspace.dependencies] centralise les dépendances partagées par plusieurs crates. Chaque service y fait référence avec { workspace = true } au lieu de fixer sa propre contrainte de version.

Cargo.toml (racine)
TOML
[workspace.dependencies]
axum = { version = "0.8", features = ["ws", "multipart", "macros"] }
sqlx = { version = "0.8", default-features = false, features = […] }
tower_governor = { version = "0.8", features = ["axum"] }
governor = "0.10"
# services/aura-gateway/Cargo.toml — n’hérite PAS du governor du workspace
governor = "0.8" # version locale, différente

C'est le second écart réel : le workspace centralise governor en version 0.10, mais aura-gateway redéclare sa propre ligne governor = "0.8" au lieu d'hériter — la passerelle applique son rate limiting avec la crate governor nue, quand aura-auth, aura-ai, aura-db et aura-functions passent par tower_governor en middleware. Un workspace n'empêche pas la divergence : il la rend seulement visible, si on prend la peine de comparer.

Toutes les dépendances ne méritent pas d'être centralisées pour autant. wasmtime n'apparaît nulle part dans [workspace.dependencies] : un seul crate, aura-functions, l'utilise pour son runtime WASM, donc elle reste déclarée localement. La règle qu'on applique : hisser une dépendance au niveau du workspace à partir du moment où deux crates ou plus la partagent, pas avant.

#
Graphe interne

libs/ vers services/ : qui dépend de quoi

Les cinq libs partagées (aura-core, aura-crypto, aura-db-adapters, aura-migrations, aura-telemetry) sont déclarées comme dépendances de chemin dans [workspace.dependencies]aura-core = { path = "libs/aura-core" } — puis chaque service choisit celles dont il a réellement besoin.

Le graphe qui en résulte reste lisible d'un fichier à l'autre. aura-gateway ne dépend que de trois des cinq libs internes — aura-core, aura-crypto et aura-telemetry. De quoi router, signer un JWT pour le sidecar PostgREST et exporter ses traces, sans toucher à aura-db-adapters ni aura-migrations. aura-provisioner, lui, ajoute aura-migrations : il rejoue les migrations du schéma tenant à la création d'un projet.

Le cas le plus étroit est aura-migrator, le binaire dédié qui exécute les migrations en CI/CD. Parmi les libs internes Aurabase, ses [dependencies] de production ne listent qu'aura-migrationsaura-core n'apparaît que dans ses [dev-dependencies], pour les tests. Le binaire livré en production n'embarque donc aucun code d'aura-core ; il n'existe que pendant cargo test.

#
Cas concret

Un crate, plusieurs binaires : le découpage aura-realtime

Un workspace n'oblige pas à créer un nouveau membre chaque fois qu'on veut un nouveau process déployable. aura-realtime reste un seul membre du workspace, mais son Cargo.toml déclare trois tables [[bin]] distinctes autour d'une même [lib] partagée.

services/aura-realtime/Cargo.toml
TOML
[lib]
name = "aura_realtime"
[[bin]]
name = "aura-realtime"
[[bin]]
name = "aura-realtime-cdc-worker"
path = "src/bin/cdc_worker.rs"
[[bin]]
name = "aura-realtime-ws-front"
path = "src/bin/ws_front.rs"

Le manifeste documente lui-même la frontière entre les deux. cdc-worker capture les changements Postgres par polling et publie sur NATS en pub/sub cœur (Client::publish_with_headers), sans jamais ouvrir de serveur WebSocket — JetStream, dans ce même service, sert exclusivement le KV de présence cross-instance, pas le fan-out CDC. ws-front ne fait que consommer NATS et tenir les connexions WebSocket/SSE, sans jamais toucher au CDC — le détail complet est dans la documentation du moteur temps réel. Les deux binaires partagent le même code de filtrage RLS via [lib], mais se déploient et scalent indépendamment en Kubernetes. C'est le bon signal pour choisir plusieurs binaires dans un crate plutôt qu'un nouveau membre de workspace : même logique interne, topologies de déploiement différentes.

#
Piège à éviter

Un dossier sous services/ n'est pas forcément un membre Cargo

Le dossier services/aura-edge-runtime existe bien dans le dépôt Aurabase. Il ne contient pourtant aucun Cargo.toml — seulement du TypeScript (index.ts, envelope.ts), et il n'apparaît nulle part dans la liste members du workspace racine.

Astuce
C'est exactement pourquoi la liste members d'Aurabase est écrite à la main plutôt que remplacée par un motif générique comme services/*. Un glob aurait tenté d'inclure ce dossier non-Rust dans le workspace, avec un échec de résolution à la clé. Une liste explicite laisse cohabiter, sous le même parent services/, des dossiers qui ne parlent pas le même langage.

La leçon se généralise : compter les services d'un backend Rust en listant les sous-dossiers de services/ donne un chiffre faux. Seul le Cargo.toml racine fait foi sur ce qui compile réellement dans le workspace.

#
Compilation

[profile.release] : un seul réglage pour tout le workspace

Un [profile.release] déclaré à la racine du workspace s'applique à tous les membres compilés en mode release — un seul endroit à ajuster, pas onze. Cargo documente l'intégralité des clés disponibles dans sa référence des profils de compilation ; Aurabase n'en active que cinq.

Cargo.toml (racine)
TOML
[profile.release]
lto = "thin" # LTO cross-crates, équilibre perf/temps de build
codegen-units = 1 # meilleur inlining global
panic = "unwind" # un panic isolé par tokio/axum → 500, pas un crash global
strip = "symbols"
opt-level = 3

Le choix de panic = "unwind" plutôt que "abort" est documenté directement dans le commentaire du fichier : un panic dans un handler axum est intercepté par tokio, la tâche renvoie une 500, et le process continue de servir les autres requêtes concurrentes. Le gain de performance d'abort ne vaut pas la perte d'isolation entre requêtes.

#
En pratique

Figer la chaîne d'outils, et les commandes qui comptent

Un workspace unifié règle les versions de dépendances, mais pas la version du compilateur lui-même. rust-toolchain.toml, à la racine, fige la chaîne d'outils (channel = "1.93" aujourd'hui) pour tout appel direct à cargo ou rustc dans le dépôt.

Ce fichier existe justement parce qu'une dérive silencieuse s'est produite : une action CI installait le canal stable du jour sans lire rust-toolchain.toml, pendant que l'image Docker de release compilait avec la version figée. Un commit pouvait passer tous les tests avec le Rust stable du jour, puis casser au build de l'image — découvert après le merge, pas avant. rustup, lui, respecte ce fichier pour toute commande directe : c'était le seul correctif nécessaire, sans toucher aux workflows CI.

terminal
BASH
# Compiler tout le workspace
cargo build --workspace
# Tester un seul crate (pas tout le workspace)
cargo test -p aura-auth
# Lint strict sur tout le workspace, warnings = erreurs
cargo clippy --workspace --all-targets -- -D warnings
# Formatage
cargo fmt --all

Un dernier détail, pour qui lit un manifeste avant de l'exécuter : le crate aura-cli compile un binaire dont la table [[bin]] le nomme aurabase, pas aura. Le wrapper npm publié, @aurabase/cli, expose lui les deux commandes — aura et aurabase pointent toutes deux vers le même script. Le nom d'un [[bin]] Cargo, le nom du crate et le nom exposé par un wrapper npm sont trois choses distinctes ; aucune ne se devine depuis les deux autres.

#
Récap

Aide-mémoire : où déclarer quoi

Cinq décisions reviennent à chaque fois qu'on ajoute un crate à un workspace Cargo. Voici où chacune se déclare, d'après l'exemple Aurabase parcouru ci-dessus.

Liste des membres[workspace] membersRacine — liste explicite, jamais un glob
Version / édition partagées[workspace.package]Racine — version.workspace = true, par crate, optionnel
Dépendance partagée par 2+ crates[workspace.dependencies]Racine — puis { workspace = true } dans chaque crate
Dépendance à un seul consommateur[dependencies] du crateLocalement, sans passer par le workspace
Profil de compilation[profile.release]Racine uniquement — s’applique à tous les membres

Pour auditer un workspace Cargo existant — le vôtre ou celui d'un projet que vous reprenez — quatre vérifications suffisent à trouver le genre d'écarts documentés plus haut :

  1. Comparez [workspace.package].version à la version de chaque crate — une valeur différente n'est pas forcément un bug, mais mérite d'être documentée.
  2. Comparez [workspace.dependencies] aux dépendances déclarées localement par chaque crate — repérez les noms présents des deux côtés avec des versions différentes.
  3. Comparez la liste members du Cargo.toml racine aux sous-dossiers réels du dépôt — un dossier absent de members n'est pas forcément un oubli.
  4. Vérifiez le nom réel de chaque binaire compilé ([[bin]] name) plutôt que de supposer qu'il correspond au nom du crate ou au nom exposé par un éventuel wrapper npm.
EXPLORER LE CODE

Le workspace complet est sous vos yeux.

Onze services, cinq libs, un seul Cargo.lock — parcourez le Cargo.toml racine d'Aurabase et sa documentation d'architecture pour voir comment tout s'assemble.

Documentation architecture Créer un projet
Aucune carte bancaire requise · 500 MB gratuits · 50 000 MAU