Rust und Hexagonale Architektur: Code schreiben, den man nicht fürchten muss zu ändern
\n\nJeder Entwickler war schon einmal in der Situation, dass eine kleine Änderung in der Datenbank oder der Wechsel des HTTP-Frameworks zu einem höllischen Refactoring führte. Die Geschäftslogik ist mit Implementierungsdetails verwoben, und der Code wird zu "Spaghetti". Für Rust, mit seinem Fokus auf Sicherheit und Leistung, ist eine solche Vermischung ein echtes Verbrechen.
\n\nDie Lösung für dieses Problem ist die Hexagonale Architektur (auch bekannt als Ports and Adapters). In diesem Artikel werden wir untersuchen, wie man dieses Muster in Rust unter Verwendung der Leistungsfähigkeit des Typsystems und der Traits anwendet. Sie erfahren, wie Sie die Domänenlogik von der Außenwelt isolieren, testbaren Code schreiben und Datenbanktreiber oder Webserver problemlos austauschen können. Dies ist eine unverzichtbare Fähigkeit für jeden, der komplexe, wartbare Systeme in Rust erstellen möchte.
\n\nWas ist die Hexagonale Architektur? (Sehr kurz)
\n\nStellen Sie sich ein Sechseck (Hexagon) vor. In der Mitte befindet sich Ihre Geschäftslogik (Domäne). An den Rändern befinden sich "Ports" (Schnittstellen), über die die Domäne mit der Außenwelt kommuniziert. Und außen befinden sich "Adapter" (konkrete Implementierungen), die an die Ports angeschlossen werden.
\n\nDie Kernidee: Die Domäne weiß nichts darüber, wie Daten gespeichert oder Anfragen gesendet werden. Sie kennt nur die Ports – Traits mit Methoden. Adapter (z. B. ein PostgreSQL-Repository oder ein HTTP-Controller) implementieren diese Traits.
\n\nIn Rust passt dieses Konzept dank Traits (analog zu Schnittstellen) und Generics perfekt. Der Compiler überprüft alle Verbindungen zur Kompilierzeit, nicht zur Laufzeit.
\n\nProjektstruktur in Rust
\n\nErstellen wir zunächst eine Ordnerstruktur, die die Domäne visuell von der Infrastruktur trennt:
\n\nsrc/\n├── domain/ # Reine Geschäftslogik (keine Abhängigkeiten!)\n│ ├── mod.rs\n│ ├── models.rs # Entitäten und Value Objects\n│ └── services.rs # Dienste, die mit Ports arbeiten\n├── ports/ # Traits (Schnittstellen) für die Außenwelt\n│ ├── mod.rs\n│ └── repositories.rs\n├── adapters/ # Implementierungen der Ports (Datenbanken, HTTP, usw.)\n│ ├── mod.rs\n│ ├── postgres_repo.rs\n│ └── in_memory_repo.rs # Für Tests\n├── main.rs # Anwendungszusammenstellung (Wiring)\n└── lib.rs # Öffentliche API der Bibliothek\n\nSchritt 1: Domäne – das Herz des Systems
\n\nBeginnen wir mit einem einfachen Benutzermodell. In der Domäne gibt es keine einzige Abhängigkeit von externen Bibliotheken (nicht einmal von serde, wenn dies nicht von der Geschäftslogik benötigt wird).
\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 // Hier könnte eine Validierung stattfinden\n Self { id, name, email }\n }\n}\n\nJetzt definieren wir einen Domänendienst. Er wird mit Benutzern arbeiten, aber nicht wissen, woher sie kommen.
\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(); // Schon eine Abhängigkeit? Ja, aber das ist ein Detail\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\nWichtig: Wir verwenden Box<dyn UserRepository> für die dynamische Dispatch. In Rust ist das für komplexe Anwendungen in Ordnung. Für einfache Fälle können Generics verwendet werden.