Erstellung von wartbaren Rust-Webanwendungen mit geschichtetem DDD
Daniel Hayes
Full-Stack Engineer · Leapcell

Einleitung
Der Aufbau robuster und skalierbarer Webanwendungen ist eine gängige Herausforderung, und mit zunehmender Komplexität von Projekten wird die Aufrechterhaltung einer klaren und organisierten Codebasis immer wichtiger. Oft vermischen sich Geschäftslogik und Infrastrukturbelange, was zu einer Anwendung führt, die schwer zu verstehen, zu testen und weiterzuentwickeln ist. Hier bietet Domain-Driven Design (DDD) einen leistungsstarken Ansatz. Durch die Konzentration auf die Kern-Domäne und die Trennung von Belangen hilft DDD uns, Systeme zu schaffen, die besser auf die Geschäftsanforderungen abgestimmt und widerstandsfähiger gegenüber Veränderungen sind.
Im Rust-Ökosystem, mit seinem Fokus auf Sicherheit, Leistung und Nebenläufigkeit, kann die Übernahme einer geschichteten DDD-Architektur besonders vorteilhaft sein. Sie ermöglicht es uns, die Stärken von Rust zu nutzen und gleichzeitig wartbare und verständliche Webdienste zu entwickeln. Dieser Artikel befasst sich damit, wie man klares, geschichtetes DDD in Rust-Webprojekten praktiziert und liefert praktische Einblicke und Codebeispiele, die Sie durch den Prozess führen.
Kernkonzepte verstehen
Bevor wir uns mit den Implementierungsdetails befassen, lassen Sie uns einige Schlüsselbegriffe klären, die das Rückgrat einer geschichteten DDD-Architektur bilden:
- Domain-Driven Design (DDD): Ein Ansatz zur Softwareentwicklung für komplexe Anforderungen durch die Verbindung der Implementierung mit einem sich entwickelnden Modell der zentralen Geschäftskonzepte.
- Geschichtete Architektur: Ein gängiges Architekturmuster, bei dem Komponenten in logische Schichten organisiert sind, von denen jede spezifische Verantwortlichkeiten hat. Höhere Schichten hängen von niedrigeren Schichten ab, aber nicht umgekehrt, was eine Trennung der Belange fördert.
- Domänenschicht: Dies ist das Herzstück einer DDD-Anwendung. Sie enthält die Geschäftslogik, Entitäten, Wertobjekte, Aggregate und Domänendienste. Sie ist unabhängig von Infrastrukturbelangen.
- Anwendungsschicht: Orchestriert die Domänenobjekte zur Ausführung spezifischer Anwendungsaufgaben. Sie fungiert als dünne Fassade über der Domänenschicht und verwaltet Anwendungsfälle und koordiniert Interaktionen. Sie enthält selbst keine Geschäftslogik.
- Infrastrukturschicht: Bietet allgemeine technische Fähigkeiten, die die höheren Schichten unterstützen, wie Persistenz (Datenbanken), externe Kommunikation (APIs), Protokollierung und Nachrichtenübermittlung.
- Benutzeroberfläche / Präsentationsschicht: Verantwortlich für die Darstellung von Informationen für den Benutzer und die Verarbeitung von Benutzereingaben. In einer Webanwendung entspricht dies oft HTTP-Endpunkten und der Verarbeitung von Anfragen und Antworten.
- Entitäten: Objekte mit einer eindeutigen Identität, die über die Zeit und verschiedene Darstellungen hinweg Bestand hat.
- Wertobjekte: Objekte, die ein Merkmal von etwas beschreiben, aber keine konzeptionelle Identität haben. Ihre Gleichheit basiert auf ihren Attributen.
- Aggregate: Eine Gruppe zusammengehöriger Objekte, die als eine Einheit für Datenänderungen behandelt werden. Ein Aggregat hat eine Root-Entität (Stamm-Entität), das einzige Mitglied des Aggregats, auf das externe Objekte Verweise halten dürfen.
- Repositories: Abstraktionen über Datenpersistenzmechanismen, die es der Domänenschicht ermöglichen, Aggregate abzurufen und zu speichern, ohne die zugrunde liegende Speichertechnologie zu kennen.
- Domänendienste: Operationen, die sich nicht natürlich in eine Entität oder ein Wertobjekt einfügen und oft mehrere Domänenobjekte koordinieren.
Praktische Schichtung in Rust-Webprojekten
Veranschaulichen wir, wie ein Rust-Webprojekt mit einem klaren, geschichteten DDD-Ansatz strukturiert werden kann. Wir verwenden ein einfaches Beispiel: eine Aufgabenverwaltungsanwendung.
Unsere typische Projektstruktur könnte in etwa so aussehen:
├── src
│ ├── main.rs
│ ├── application // Anwendungsschicht
│ │ ├── commands // Für Schreiboperationen
│ │ ├── queries // Für Leseoperationen
│ │ └── services // Anwendungsdienste, die die Domäne orchestrieren
│ ├── domain // Domänenschicht
│ │ ├── entities
│ │ ├── errors
│ │ ├── repositories
│ │ ├── services
│ │ └── value_objects
│ ├── infrastructure // Infrastrukturschicht
│ │ ├── database // Persistenz (z. B. SQLx, Diesel)
│ │ ├── web // Webserver (z. B. Actix Web, Axum)
│ │ └── ... // Andere Infrastrukturbelange
│ └── presentation // Präsentationsschicht (oft innerhalb von infrastructure/web)
│ ├── handlers
│ └── models // DTOs für die Präsentation
└── Cargo.toml
Domänenschicht
Hier befindet sich unsere Kern-Geschäftslogik. Sie sollte unabhängig von Frameworks oder externen Bibliotheken sein, die nicht direkt domänenspezifisch sind.
src/domain/entities.rs
use crate::domain::value_objects::{TaskId, TaskDescription, TaskStatus}; #[derive(Debug, PartialEq, Eq, Clone)] pub struct Task { pub id: TaskId, pub description: TaskDescription, pub status: TaskStatus, } impl Task { pub fn new(id: TaskId, description: TaskDescription, status: TaskStatus) -> Self { Self { id, description, status } } pub fn mark_as_completed(&mut self) { if self.status != TaskStatus::Completed { self.status = TaskStatus::Completed; } } pub fn update_description(&mut self, new_description: TaskDescription) { self.description = new_description; } }
src/domain/value_objects.rs
use uuid::Uuid; use std::fmt; #[derive(Debug, PartialEq, Eq, Clone, Hash)] pub struct TaskId(Uuid); impl TaskId { pub fn new() -> Self { Self(Uuid::new_v4()) } pub fn from_uuid(id: Uuid) -> Self { Self(id) } pub fn into_uuid(self) -> Uuid { self.0 } } impl fmt::Display for TaskId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } pub struct InvalidTaskDescriptionError; #[derive(Debug, PartialEq, Eq, Clone)] pub struct TaskDescription(String); impl TaskDescription { pub fn new(description: String) -> Result<Self, InvalidTaskDescriptionError> { if description.is_empty() || description.len() > 255 { return Err(InvalidTaskDescriptionError); } Ok(Self(description)) } pub fn as_str(&self) -> &str { &self.0 } } #[derive(Debug, PartialEq, Eq, Clone)] pub enum TaskStatus { Pending, InProgress, Completed, }
src/domain/repositories.rs
Dieses Trait definiert den Vertrag für die Interaktion mit Task
-Daten. Die Domänenschicht kümmert sich nicht darum, wie sie gespeichert wird, sondern nur, welche Operationen verfügbar sind.
use async_trait::async_trait; use crate::domain::entities::Task; use crate::domain::value_objects::TaskId; use crate::domain::errors::DomainError; use std::error::Error; #[async_trait] pub trait TaskRepository: Send + Sync { async fn find_by_id(&self, id: &TaskId) -> Result<Option<Task>, Box<dyn Error>>; async fn save(&self, task: &Task) -> Result<(), Box<dyn Error>>; async fn delete(&self, id: &TaskId) -> Result<(), Box<dyn Error>>; async fn find_all(&self) -> Result<Vec<Task>, Box<dyn Error>>; }
Anwendungsschicht
Diese Schicht enthält Anwendungsdienste, die Domänenobjekte verwenden, um spezifische Anwendungsfälle zu erfüllen. Sie enthalten selbst keine Geschäftslogik, sondern orchestrieren Domänenobjekte, um ein Ziel zu erreichen.
src/application/commands.rs
use crate::domain::value_objects::{TaskDescription, TaskId, TaskStatus}; pub struct CreateTaskCommand { pub description: String, } pub struct UpdateTaskDescriptionCommand { pub task_id: String, pub new_description: String, } pub struct MarkTaskCompletedCommand { pub task_id: String, }
src/application/services.rs
use std::sync::Arc; use crate::application::commands::{CreateTaskCommand, MarkTaskCompletedCommand, UpdateTaskDescriptionCommand}; use crate::domain::entities::Task; use crate::domain::repositories::TaskRepository; use crate::domain::value_objects::{TaskId, TaskDescription, TaskStatus}; use crate::domain::errors::DomainError; use uuid::Uuid; use std::error::Error; pub struct TaskService<T: TaskRepository> { task_repository: Arc<T>, } impl<T: TaskRepository> TaskService<T> { pub fn new(task_repository: Arc<T>) -> Self { Self { task_repository } } pub async fn create_task(&self, command: CreateTaskCommand) -> Result<TaskId, Box<dyn Error>> { let task_id = TaskId::new(); let description = TaskDescription::new(command.description) .map_err(|_| DomainError::ValidationError("Ungültige Aufgabenbeschreibung".to_string()))?; let task = Task::new(task_id.clone(), description, TaskStatus::Pending); self.task_repository.save(&task).await?; Ok(task_id) } pub async fn update_task_description(&self, command: UpdateTaskDescriptionCommand) -> Result<(), Box<dyn Error>> { let task_id = TaskId::from_uuid(Uuid::parse_str(&command.task_id)?); let mut task = self.task_repository.find_by_id(&task_id).await? .ok_or(DomainError::NotFound(format!("Task mit ID {} nicht gefunden", task_id))) ?; let new_description = TaskDescription::new(command.new_description) .map_err(|_| DomainError::ValidationError("Ungültige Aufgabenbeschreibung".to_string()))?; task.update_description(new_description); self.task_repository.save(&task).await?; Ok(()) } pub async fn mark_task_completed(&self, command: MarkTaskCompletedCommand) -> Result<(), Box<dyn Error>> { let task_id = TaskId::from_uuid(Uuid::parse_str(&command.task_id)?); let mut task = self.task_repository.find_by_id(&task_id).await? .ok_or(DomainError::NotFound(format!("Task mit ID {} nicht gefunden", task_id))) ?; task.mark_as_completed(); self.task_repository.save(&task).await?; Ok(()) } pub async fn get_task_by_id(&self, task_id: &str) -> Result<Option<Task>, Box<dyn Error>> { let id = TaskId::from_uuid(Uuid::parse_str(task_id)?); self.task_repository.find_by_id(&id).await } pub async fn get_all_tasks(&self) -> Result<Vec<Task>, Box<dyn Error>> { self.task_repository.find_all().await } }
Infrastrukturschicht (Persistenzbeispiel)
Diese Schicht implementiert das TaskRepository
-Trait aus der Domänenschicht und interagiert typischerweise mit einer Datenbank.
src/infrastructure/database/models.rs
Data Transfer Objects (DTOs) für die Datenbankinteraktion.
use sqlx::FromRow; use uuid::Uuid; use crate::domain::value_objects::TaskStatus; #[derive(FromRow)] pub struct TaskModel { pub id: Uuid, pub description: String, pub status: String, // Als String in der DB gespeichert } impl From<TaskModel> for crate::domain::entities::Task { fn from(model: TaskModel) -> Self { crate::domain::entities::Task::new( crate::domain::value_objects::TaskId::from_uuid(model.id), crate::domain::value_objects::TaskDescription::new(model.description).expect("Ungültige Beschreibung aus der DB"), // In realer App besser handhaben TaskStatus::from(model.status.as_str()), ) } } impl From<&crate::domain::entities::Task> for TaskModel { fn from(task: &crate::domain::entities::Task) -> Self { TaskModel { id: task.id.into_uuid(), description: task.description.as_str().to_string(), status: task.status.to_string(), } } } impl From<&str> for TaskStatus { fn from(s: &str) -> Self { match s { "Pending" => TaskStatus::Pending, "InProgress" => TaskStatus::InProgress, "Completed" => TaskStatus::Completed, _ => TaskStatus::Pending, // Standard oder Fehlerbehandlung } } } impl ToString for TaskStatus { fn to_string(&self) -> String { match self { TaskStatus::Pending => "Pending".to_string(), TaskStatus::InProgress => "InProgress".to_string(), TaskStatus::Completed => "Completed".to_string(), } } }
src/infrastructure/database/repositories.rs
use async_trait::async_trait; use sqlx::{PgPool, Error as SqlxError}; use std::sync::Arc; use crate::domain::entities::Task; use crate::domain::repositories::TaskRepository; use crate::domain::value_objects::TaskId; use crate::infrastructure::database::models::TaskModel; use std::error::Error; pub struct PgTaskRepository { pool: Arc<PgPool>, } impl PgTaskRepository { pub fn new(pool: Arc<PgPool>) -> Self { Self { pool } } } #[async_trait] impl TaskRepository for PgTaskRepository { async fn find_by_id(&self, id: &TaskId) -> Result<Option<Task>, Box<dyn Error>> { let task_model = sqlx::query_as!( TaskModel, "SELECT id, description, status FROM tasks WHERE id = $1", id.into_uuid() ) .fetch_optional(&*self.pool) .await?; Ok(task_model.map(Task::from)) } async fn save(&self, task: &Task) -> Result<(), Box<dyn Error>> { let task_model = TaskModel::from(task); sqlx::query!( "INSERT INTO tasks (id, description, status) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET description = $2, status = $3", task_model.id, task_model.description, task_model.status ) .execute(&*self.pool) .await?; Ok(()) } async fn delete(&self, id: &TaskId) -> Result<(), Box<dyn Error>> { sqlx::query!("DELETE FROM tasks WHERE id = $1", id.into_uuid()) .execute(&*self.pool) .await?; Ok(()) } async fn find_all(&self) -> Result<Vec<Task>, Box<dyn Error>> { let task_models = sqlx::query_as!( TaskModel, "SELECT id, description, status FROM tasks" ) .fetch_all(&*self.pool) .await?; Ok(task_models.into_iter().map(Task::from).collect()) } }
Präsentationsschicht (Web-Handler-Beispiel)
Diese Schicht befasst sich mit HTTP-Anfragen und -Antworten, übersetzt diese in Anwendungsbefehle/-abfragen und sendet geeignete Antworten zurück. Wir verwenden ein vereinfachtes Beispiel ohne ein spezifisches Web-Framework, um die Interaktion zu demonstrieren.
src/presentation/models.rs
DTOs für API-Anfragen/-Antworten.
use serde::{Deserialize, Serialize}; #[derive(Deserialize)] pub struct CreateTaskRequest { pub description: String, } #[derive(Serialize)] pub struct TaskResponse { pub id: String, pub description: String, pub status: String, }
src/presentation/handlers.rs
(Konzeptionell – dies würde mit einem Web-Framework wie Axum oder Actix integriert werden)
use std::error::Error; use std::sync::Arc; use crate::application::commands::{CreateTaskCommand, MarkTaskCompletedCommand, UpdateTaskDescriptionCommand}; use crate::application::services::TaskService; use crate::domain::repositories::TaskRepository; use crate::domain::value_objects::TaskStatus; use crate::presentation::models::{CreateTaskRequest, TaskResponse}; // Diese Struktur wäre typischerweise Teil des Zustandsmanagements Ihres Webservers pub struct TaskHandler<T: TaskRepository> { task_service: Arc<TaskService<T>>, } impl<T: TaskRepository> TaskHandler<T> { pub fn new(task_service: Arc<TaskService<T>>) -> Self { Self { task_service } } // Beispiel: HTTP POST /tasks pub async fn create_task(&self, req: CreateTaskRequest) -> Result<TaskResponse, Box<dyn Error>> { let command = CreateTaskCommand { description: req.description }; let task_id = self.task_service.create_task(command).await?; Ok(TaskResponse { id: task_id.to_string(), description: req.description, // Vereinfacht, idealerweise vollständige Aufgabe abrufen status: TaskStatus::Pending.to_string(), }) } // Beispiel: HTTP GET /tasks/{id} pub async fn get_task_by_id(&self, task_id: &str) -> Result<Option<TaskResponse>, Box<dyn Error>> { let task = self.task_service.get_task_by_id(task_id).await?; Ok(task.map(|t| TaskResponse { id: t.id.to_string(), description: t.description.as_str().to_string(), status: t.status.to_string(), })) } // Beispiel: HTTP PUT /tasks/{id}/complete pub async fn mark_task_as_completed(&self, task_id: &str) -> Result<(), Box<dyn Error>> { let command = MarkTaskCompletedCommand { task_id: task_id.to_string() }; self.task_service.mark_task_completed(command).await?; Ok(()) } }
Zusammenführen (Hauptanwendung)
Die main.rs
würde die Abhängigkeitsinjektion durchführen und den Webserver starten.
// src/main.rs (vereinfacht für die Demonstration) use std::sync::Arc; use sqlx::PgPool; use anyhow::Result; use crate::application::services::TaskService; use crate::infrastructure::database::repositories::PgTaskRepository; use crate::presentation::handlers::TaskHandler; mod domain; mod application; mod infrastructure; mod presentation; #[tokio::main] async fn main() -> Result<()> { // 1. Infrastruktur initialisieren (z. B. Datenbank-Pool) let database_url = std::env::var("DATABASE_URL") .expect("DATABASE_URL muss gesetzt sein"); let pool = PgPool::connect(&database_url).await?; sqlx::migrate!("./migrations").run(&pool).await?; // Migrationen ausführen let db_pool = Arc::new(pool); // 2. Infrastruktur-Repositories instanziieren let task_repository = Arc::new(PgTaskRepository::new(Arc::clone(&db_pool))); // 3. Anwendungsdienste mit Repositories instanziieren let task_service = Arc::new(TaskService::new(Arc::clone(&task_repository))); // 4. Präsentationshandler mit Anwendungsdiensten instanziieren let task_handler = TaskHandler::new(Arc::clone(&task_service)); // In einer realen Anwendung würden Sie hier einen Webserver konfigurieren und starten, // wie Axum oder Actix-web, und `task_handler` oder seine Methoden an Routen übergeben. // Zum Beispiel: // let app = Router::new() // .route("/tasks", post(move |req| task_handler.create_task(req))) // .route("/tasks/:id", get(move |id| task_handler.get_task_by_id(id))); // // let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); // axum::serve(listener, app).await.unwrap(); println!("Anwendung konfiguriert. Webserver würde hier starten."); println!("Beispiel: task_handler.create_task(...) kann jetzt aufgerufen werden"); Ok(()) }
Vorteile dieses geschichteten Ansatzes:
- Trennung der Belange: Jede Schicht hat eine eigene Verantwortung, was die Codebasis leichter verständlich und verwaltbar macht.
- Testbarkeit: Die Domänenschicht kann isoliert getestet werden, ohne dass eine Datenbank oder ein Webserver erforderlich ist. Die Anwendungsschicht kann durch Mocking von Repositories getestet werden.
- Wartbarkeit: Änderungen in einer Schicht (z. B. Wechseln der Datenbank in der Infrastrukturschicht) haben minimale Auswirkungen auf andere Schichten.
- Flexibilität: Die Kern-Geschäftslogik bleibt unabhängig, sodass verschiedene Präsentationen (z. B. eine CLI oder eine mobile App) auf denselben Domänen- und Anwendungsschichten aufgebaut werden können.
- Reduzierte Kopplung: Abhängigkeiten fließen nach unten, was bedeutet, dass höhere Schichten von Abstraktionen niedrigerer Schichten abhängen, nicht von konkreten Implementierungen.
Fazit
Die Implementierung eines klaren, geschichteten Domain-Driven Design in Rust-Webprojekten ist eine leistungsstarke Strategie, um wartbare, skalierbare und testbare Anwendungen zu erstellen. Indem wir die Kern-Domänenlogik sorgfältig von der Anwendungs-Orchestrierung und den Infrastrukturbelangen trennen, befähigen wir unsere Teams, sich auf den Geschäftswert zu konzentrieren und gleichzeitig die inhärenten Stärken von Rust zu nutzen. Dieser architektonische Ansatz bringt nicht nur Klarheit in komplexe Systeme, sondern legt auch ein solides Fundament für zukünftiges Wachstum und Anpassung. Die Übernahme von geschichtetem DDD führt zu robusteren und qualitativ hochwertigeren Rust-Webdiensten, die sich mit Freude weiterentwickeln lassen.