Crafting Maintainable Rust Web Apps with Layered DDD
Daniel Hayes
Full-Stack Engineer · Leapcell

Introduction
Building robust and scalable web applications is a common challenge, and as projects grow in complexity, maintaining a clear and organized codebase becomes paramount. Often, business logic gets intertwined with infrastructure concerns, leading to an application that is difficult to understand, test, and evolve. This is where Domain-Driven Design (DDD) offers a powerful approach. By focusing on the core domain and separating concerns, DDD helps us create systems that are more aligned with business needs and more resilient to change.
In the Rust ecosystem, with its emphasis on safety, performance, and concurrency, adopting a layered DDD architecture can be particularly beneficial. It allows us to leverage Rust's strengths while constructing maintainable and understandable web services. This article will delve into how to practice clear, layered DDD in Rust web projects, providing practical insights and code examples to guide you through the process.
Understanding the Core Concepts
Before we dive into the implementation details, let's clarify some key terms that form the backbone of a layered DDD architecture:
- Domain-Driven Design (DDD): An approach to software development for complex needs by connecting the implementation to an evolving model of the core business concepts.
- Layered Architecture: A common architectural pattern where components are organized into logical layers, each with specific responsibilities. Higher layers depend on lower layers, but not vice-versa, promoting separation of concerns.
- Domain Layer: This is the heart of a DDD application. It contains the business logic, entities, value objects, aggregates, and domain services. It is independent of any infrastructure concerns.
- Application Layer: Orchestrates the domain objects to perform specific application tasks. It acts as a thin facade over the domain layer, handling use cases and coordinating interactions. It doesn't contain business logic itself.
- Infrastructure Layer: Provides generic technical capabilities that support the higher layers, such as persistence (databases), external communication (APIs), logging, and messaging.
- User Interface / Presentation Layer: Responsible for presenting information to the user and handling user input. In a web application, this often translates to HTTP endpoints and request/response handling.
- Entities: Objects with a distinct identity that runs through time and different representations.
- Value Objects: Objects that describe a characteristic of something but have no conceptual identity. Their equality is based on their attributes.
- Aggregates: A cluster of associated objects that are treated as a unit for data changes. An aggregate has a root entity, which is the only member of the aggregate that outside objects are allowed to hold references to.
- Repositories: Abstractions over data persistence mechanisms, allowing the domain layer to retrieve and save aggregates without knowing the underlying storage technology.
- Domain Services: Operations that don't naturally fit within an entity or value object, often coordinating multiple domain objects.
Practical Layering in Rust Web Projects
Let's illustrate how to structure a Rust web project with a clean, layered DDD approach. We'll use a simple example: a task management application.
Our typical project structure might look something like this:
├── src
│ ├── main.rs
│ ├── application // Application layer
│ │ ├── commands // For write operations
│ │ ├── queries // For read operations
│ │ └── services // Application services that orchestrate domain
│ ├── domain // Domain layer
│ │ ├── entities
│ │ ├── errors
│ │ ├── repositories
│ │ ├── services
│ │ └── value_objects
│ ├── infrastructure // Infrastructure layer
│ │ ├── database // Persistence (e.g., SQLx, Diesel)
│ │ ├── web // Web server (e.g., Actix Web, Axum)
│ │ └── ... // Other infrastructure concerns
│ └── presentation // Presentation layer (often within infrastructure/web)
│ ├── handlers
│ └── models // DTOs for presentation
└── Cargo.toml
Domain Layer
This is where our core business logic resides. It should be independent of any frameworks or external libraries that are not directly domain-related.
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
This trait defines the contract for how we interact with Task
data. The domain layer doesn't care how it's stored, only what operations are available.
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>>; }
Application Layer
This layer contains application services that use the domain objects to satisfy specific use cases. They don't contain business logic themselves, but orchestrate domain objects to achieve a goal.
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("Invalid task description".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 with ID {} not found", task_id)))?; let new_description = TaskDescription::new(command.new_description) .map_err(|_| DomainError::ValidationError("Invalid task description".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 with ID {} not found", 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 } }
Infrastructure Layer (Persistence Example)
This layer implements the TaskRepository
trait defined in the domain layer, typically interacting with a database.
src/infrastructure/database/models.rs
Data Transfer Objects (DTOs) for database interaction.
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, // Stored as string in DB } 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("invalid description from db"), // Should handle better in real app 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, // Default or error handling } } } 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()) } }
Presentation Layer (Web Handlers Example)
This layer deals with HTTP requests and responses, translating them into application layer commands/queries and sending back appropriate responses. We'll use a simplified example without a specific web framework for brevity, demonstrating the interaction.
src/presentation/models.rs
DTOs for API request/response.
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
(Conceptual - this would integrate with a web framework like Axum or Actix)
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}; // This struct would typically be part of your web server's state management 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 } } // Example: 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, // Simplified, ideally retrieve full task status: TaskStatus::Pending.to_string(), }) } // Example: 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(), })) } // Example: 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(()) } }
Putting it Together (Main Application)
The main.rs
would perform dependency injection and start the web server.
// src/main.rs (simplified for 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. Initialize Infrastructure (e.g., Database Pool) let database_url = std::env::var("DATABASE_URL") .expect("DATABASE_URL must be set"); let pool = PgPool::connect(&database_url).await?; sqlx::migrate!("./migrations").run(&pool).await?; // Run migrations let db_pool = Arc::new(pool); // 2. Instantiate Infrastructure Repositories let task_repository = Arc::new(PgTaskRepository::new(Arc::clone(&db_pool))); // 3. Instantiate Application Services with Repositories let task_service = Arc::new(TaskService::new(Arc::clone(&task_repository))); // 4. Instantiate Presentation Handlers with Application Services let task_handler = TaskHandler::new(Arc::clone(&task_service)); // In a real application, you'd configure and start a web server here, // like Axum or Actix-web, passing `task_handler` or its methods to routes. // For example: // 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!("Application configured. Web server would start here."); println!("Example: task_handler.create_task(...) can now be called"); Ok(()) }
Benefits of this layered approach:
- Separation of Concerns: Each layer has a distinct responsibility, making the codebase easier to understand and manage.
- Testability: The domain layer can be tested in isolation without needing a database or web server. The application layer can be tested by mocking repositories.
- Maintainability: Changes in one layer (e.g., switching databases in the infrastructure layer) have minimal impact on other layers.
- Flexibility: The core business logic remains independent, allowing for different presentations (e.g., a CLI or a mobile app) to be built on top of the same domain and application layers.
- Reduced Coupling: Dependencies flow downwards, meaning higher layers depend on abstractions from lower layers, not concrete implementations.
Conclusion
Implementing a clear, layered Domain-Driven Design in Rust web projects is a powerful strategy for building maintainable, scalable, and testable applications. By carefully separating the core domain logic from application orchestration and infrastructure concerns, we empower our teams to focus on business value while leveraging Rust's inherent strengths. This architectural approach not only brings clarity to complex systems but also lays a solid foundation for future growth and adaptation. Embracing layered DDD will lead to more robust and higher-quality Rust web services that are a joy to evolve.