Architecture Cargo workspace : comment structurer un backend Rust multi-services
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.
- Le workspace Cargo racine d'Aurabase déclare 18 membres explicites : 11 services, la CLI
aura-cli, 5 libs partagées et le SDKaurabase-rs— sousresolver = "2"et un seulCargo.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 listemembersest 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 commepanic = "unwind"vaut alors pour les onze services d'un coup.
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.
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/*.
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.
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.
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.
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.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.
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.
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-migrations — aura-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.
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.
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.
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.
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.
[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.
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.
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.
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.
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.
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 :
- Comparez
[workspace.package].versionà laversionde chaque crate — une valeur différente n'est pas forcément un bug, mais mérite d'être documentée. - 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. - Comparez la liste
membersduCargo.tomlracine aux sous-dossiers réels du dépôt — un dossier absent demembersn'est pas forcément un oubli. - 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.