Rust et l'architecture Hexagonale : écrivons un code qui ne fait pas peur à modifier
\n\nChaque développeur a déjà été confronté à une situation où un petit changement dans la base de données ou le changement d'un framework HTTP se transformait en un enfer de refactoring. La logique métier s'entremêle avec les détails d'implémentation, et le code se transforme en "spaghetti". Pour Rust, avec son accent sur la sécurité et la performance, un tel mélange est un véritable crime.
\n\nLa solution à ce problème est l'architecture Hexagonale (également connue sous le nom de Ports and Adapters). Dans cet article, nous allons voir comment appliquer ce pattern en Rust, en utilisant la puissance du système de types et des traits. Vous apprendrez à isoler la logique métier du monde extérieur, à écrire du code testable et à changer facilement de pilotes de base de données ou de serveur web. C'est une compétence obligatoire pour quiconque souhaite construire des systèmes complexes et maintenables en Rust.
\n\nQu'est-ce que l'architecture Hexagonale ? (Très brièvement)
\n\nImaginez un hexagone. Au centre se trouve votre logique métier (le domaine). Sur les bords se trouvent les "ports" (interfaces) par lesquels le domaine communique avec le monde extérieur. Et à l'extérieur se trouvent les "adaptateurs" (implémentations concrètes) qui se connectent aux ports.
\n\nIdée clé : le domaine ne sait rien de la manière dont les données sont sauvegardées ou dont les requêtes sont envoyées. Il ne connaît que les ports — des traits avec des méthodes. Les adaptateurs (par exemple, un repository PostgreSQL ou un contrôleur HTTP) implémentent ces traits.
\n\nEn Rust, ce concept s'intègre parfaitement grâce aux traits (équivalent des interfaces) et aux génériques. Le compilateur vérifie toutes les connexions à la compilation, et non à l'exécution.
\n\nStructure du projet en Rust
\n\nPour commencer, créons une structure de dossiers qui sépare visuellement le domaine de l'infrastructure :
\n\nsrc/\n├── domain/ # Logique métier pure (aucune dépendance !)\n│ ├── mod.rs\n│ ├── models.rs # Entités et Value Objects\n│ └── services.rs # Services travaillant avec les ports\n├── ports/ # Traits (interfaces) pour le monde extérieur\n│ ├── mod.rs\n│ └── repositories.rs\n├── adapters/ # Implémentations des ports (bases de données, HTTP, etc.)\n│ ├── mod.rs\n│ ├── postgres_repo.rs\n│ └── in_memory_repo.rs # Pour les tests\n├── main.rs # Assemblage de l'application (Wiring)\n└── lib.rs # API publique de la bibliothèque\n\nÉtape 1 : Le domaine — le cœur du système
\n\nCommençons par un modèle simple d'utilisateur. Dans le domaine, il n'y a aucune dépendance vis-à-vis de bibliothèques externes (même pas serde, si la logique métier ne l'exige pas).
\n\n// src/domain/models.rs\npub struct User {\n pub id: String,\n pub name: String,\n pub email: String,\n}\n\nimpl User {\n pub fn new(id: String, name: String, email: String) -> Self {\n // Une validation peut avoir lieu ici\n Self { id, name, email }\n }\n}\n\nDéfinissons maintenant un service métier. Il travaillera avec les utilisateurs, mais ne saura pas d'où ils viennent.
\n\n// src/domain/services.rs\nuse crate::ports::repositories::UserRepository;\n\npub struct UserService {\n repo: Box<dyn UserRepository>,\n}\n\nimpl UserService {\n pub fn new(repo: Box<dyn UserRepository>) -> Self {\n Self { repo }\n }\n\n pub fn create_user(&mut self, name: String, email: String) -> Result<User, String> {\n let id = uuid::Uuid::new_v4().to_string(); // Déjà une dépendance ? Oui, mais c'est un détail\n let user = User::new(id, name, email);\n self.repo.save(&user)?;\n Ok(user)\n }\n\n pub fn get_user(&self, id: &str) -> Result<Option<User>, String> {\n self.repo.find_by_id(id)\n }\n}\n\nImportant : Nous utilisons Box<dyn UserRepository> pour la répartition dynamique. En Rust, c'est normal pour les applications complexes. Pour les cas simples, on peut utiliser des génériques.