Modular Design for Robust Rust Web Projects
Min-jun Kim
Dev Intern · Leapcell

Introduction
Building large-scale web applications presents a unique set of challenges. As projects grow in complexity, the codebase can quickly become unmanageable, leading to decreased development velocity, increased bug counts, and a frustrating developer experience. This is especially true in a performance-critical language like Rust, where careful architectural decisions can have a profound impact on both compilation times and runtime performance. For Rust web frameworks like Actix Web and Axum, adopting a well-thought-out modular design is not just a best practice; it's a necessity for fostering maintainability, scalability, and collaborative development. This article will delve into how to effectively structure large Actix Web and Axum projects, guiding you through the principles and practical implementations of modularity to ensure your application remains robust and adaptable as it evolves.
Understanding Modularity in Rust Web Projects
Before we dive into concrete examples, let's clarify some core concepts surrounding modularity in the context of Rust web development.
Modularity: At its core, modularity is the practice of breaking down a system into smaller, independent, and interchangeable components called modules. Each module should encapsulate a specific piece of functionality, exposing a well-defined interface while hiding its internal implementation details.
Crates: In Rust, a crate is the fundamental unit of compilation and is also the unit of versioning and distribution. A project can consist of a single binary crate or a library crate, or it can be a "workspace" composed of multiple interdependent crates.
Modules (files system): Within a crate, code is organized into modules using the mod
keyword and file system hierarchy. These modules help organize code logically and control visibility.
Domain-Driven Design (DDD): A software development approach that emphasizes understanding and modeling the "domain" or subject matter of the software. Key concepts include:
- Domain: The subject area to which the user applies the program.
- Bounded Context: A logical boundary within which a specific domain model is defined and applicable. It helps manage complexity by isolating different parts of a large system.
- Entities: Objects defined by their identity, not just their attributes (e.g., a "User" with a unique ID).
- Value Objects: Objects defined entirely by their attributes (e.g., a "Money" object with amount and currency).
- Aggregates: A cluster of associated objects that are treated as a single unit for data changes. An Aggregate will have one root Entity.
- Repositories: Abstractions for fetching and storing aggregates.
- Services: Operations that don't naturally fit within an Entity or Value Object and often orchestrate across multiple aggregates.
Layered Architecture: A common architectural pattern that divides an application into distinct conceptual layers, each with specific responsibilities. A typical web application might include:
- Presentation/API Layer: Handles HTTP requests, authentication, data serialization/deserialization (e.g., Actix Web/Axum handlers).
- Application/Service Layer: Orchestrates business logic, calls domain services, and manipulates repositories.
- Domain Layer: Contains the core business logic, entities, value objects, and domain services. This layer should be framework-agnostic.
- Infrastructure Layer: Deals with external concerns like databases, file systems, external APIs, and message queues.
By leveraging these concepts, we can design highly decoupled and maintainable web services.
Principles and Implementation
The core idea behind modular design is to achieve high cohesion (elements within a module belong together) and low coupling (modules are independent and have minimal dependencies on each other).
1. Project Structure with Workspaces
For large projects, Rust's workspace feature is invaluable. It allows you to manage multiple related crates together. This is a common strategy to separate different logical components into their own crates.
Consider a multi-service application or an application with distinct logical boundaries:
my_big_project/
├── Cargo.toml # Workspace Cargo.toml
├── services/
│ ├── user-service/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # Actix Web/Axum app for users
│ ├── product-service/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # Actix Web/Axum app for products
│ └── order-service/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # Actix Web/Axum app for orders
└── shared_crates/
├── domain/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # Core business logic, entities, value objects
├── infrastructure/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # Database access (e.g., SQLx), external API clients
└── common_types/
├── Cargo.toml
└── src/
└── lib.rs # Common DTOs, error types
my_big_project/Cargo.toml
:
[workspace] members = [ "services/user-service", "services/product-service", "services/order-service", "shared_crates/domain", "shared_crates/infrastructure", "shared_crates/common_types", ] [workspace.dependencies] # Define common dependencies here to ensure consistent versions. # E.g., for Axum services: tokio = { version = "1.36", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } # ... other common dependencies
Each services/*
crate would then depend on the relevant shared_crates/*
through their individual Cargo.toml
files. For instance, user-service/Cargo.toml
might include:
[dependencies] axum = "0.7.4" tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } domain = { path = "../../shared_crates/domain" } infrastructure = { path = "../../shared_crates/infrastructure" } common_types = { path = "../../shared_crates/common_types" } # ... other service-specific dependencies
This structure clearly separates concerns:
- Each
service
crate is an independent deployable unit. domain
holds the core universal business logic, independent of web frameworks or databases.infrastructure
encapsulates external dependencies.common_types
prevents duplication of shared data structures.
2. Layered Architecture within a Crate
Even within a single binary crate (e.g., a simple web service that isn't broken into microservices yet), a layered architecture using Rust's module system is crucial.
Let's take a user-service
as an example, applying the layered architecture principles:
user-service/
└── src/
├── main.rs # Entry point, initializes server, state
├── api/ # Presentation/API Layer
│ ├── mod.rs # Defines routes, state extraction
│ ├── handlers/ # HTTP request handlers
│ │ ├── mod.rs
│ │ └── user_handler.rs
│ └── dtos/ # Data Transfer Objects for API input/output
│ └── mod.rs
├── application/ # Application/Service Layer
│ ├── mod.rs
│ ├── services/ # Orchestrates domain logic, interacts with repositories
│ │ ├── mod.rs
│ │ └── user_app_service.rs
│ └── commands/ # Input to application services
│ └── mod.rs
├── domain/ # Domain Layer
│ ├── mod.rs
│ ├── entities/ # User entity, etc.
│ │ └── mod.rs
│ ├── value_objects/ # UserId, Email, Password, etc.
│ │ └── mod.rs
│ ├── services/ # Pure domain logic (e.g., password hashing)
│ │ └── mod.rs
│ └── repositories/ # Traits defining repository interfaces
│ └── mod.rs
└── infrastructure/ # Infrastructure Layer
├── mod.rs
├── persistence/ # Database implementations of repository traits
│ ├── mod.rs
│ └── user_repository_impl.rs
├── config.rs # Application configuration loading
└── error.rs # Custom error handling
Example Code Snippets (Axum):
domain/repositories/user_repository.rs
(Trait Definition for Repository):
use async_trait::async_trait; use crate::domain::entities::User; use crate::domain::value_objects::UserId; use crate::infrastructure::error::ServiceError; #[async_trait] pub trait UserRepository { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, ServiceError>; async fn save(&self, user: &mut User) -> Result<(), ServiceError>; // ... other user-related database operations }
infrastructure/persistence/user_repository_impl.rs
(Concrete Database Implementation):
use async_trait::async_trait; use sqlx::{PgPool, FromRow}; use crate::domain::entities::User; use crate::domain::value_objects::{UserId, Email}; use crate::domain::repositories::UserRepository; use crate::infrastructure::error::ServiceError; // This struct represents the database schema for a User #[derive(Debug, Clone, FromRow)] struct UserDb { id: String, email: String, username: String, // ... other fields } pub struct PgUserRepository { pool: PgPool, } impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } // Convert from DB model to Domain Entity impl TryFrom<UserDb> for User { type Error = ServiceError; // Or a more specific domain error fn try_from(db_user: UserDb) -> Result<Self, Self::Error> { Ok(User::new( UserId::new(&db_user.id).map_err(|e| ServiceError::InternalServerError(e.to_string()))?, Email::new(&db_user.email).map_err(|e| ServiceError::InternalServerError(e.to_string()))?, db_user.username, // ... )) } } // Convert from Domain Entity to DB model impl From<&User> for UserDb { fn from(user: &User) -> Self { UserDb { id: user.id().to_string(), email: user.email().to_string(), username: user.username().to_string(), // ... } } } #[async_trait] impl UserRepository for PgUserRepository { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, ServiceError> { let user_db = sqlx::query_as!(UserDb, "SELECT id, email, username FROM users WHERE id = $1", id.to_string()) .fetch_optional(&self.pool) .await .map_err(|e| ServiceError::DatabaseError(e.to_string()))?; user_db.map(|u| u.try_into()).transpose() } async fn save(&self, user: &mut User) -> Result<(), ServiceError> { let user_db: UserDb = user.into(); sqlx::query!( "INSERT INTO users (id, email, username) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, username = $3", user_db.id, user_db.email, user_db.username ) .execute(&self.pool) .await .map_err(|e| ServiceError::DatabaseError(e.to_string()))?; Ok(()) } }
application/services/user_app_service.rs
(Application Service):
use std::sync::Arc; use crate::domain::entities::User; use crate::domain::value_objects::{UserId, Email}; use crate::domain::repositories::UserRepository; use crate::api::dtos::{CreateUserRequest, UserResponse}; use crate::infrastructure::error::ServiceError; pub struct UserApplicationService<R: UserRepository> { user_repository: Arc<R>, } impl<R: UserRepository> UserApplicationService<R> { pub fn new(user_repository: Arc<R>) -> Self { Self { user_repository } } pub async fn create_user(&self, request: CreateUserRequest) -> Result<UserResponse, ServiceError> { let email = Email::new(&request.email) .map_err(|e| ServiceError::BadRequest(e.to_string()))?; // Example: Check if user already exists // if self.user_repository.find_by_email(&email).await?.is_some() { // return Err(ServiceError::Conflict("User with this email already exists".to_string())); // } let mut user = User::new_with_generated_id(email, request.username); self.user_repository.save(&mut user).await?; Ok(UserResponse { id: user.id().to_string(), email: user.email().to_string(), username: user.username().to_string(), }) } pub async fn get_user_by_id(&self, id: &str) -> Result<Option<UserResponse>, ServiceError> { let user_id = UserId::new(id) .map_err(|e| ServiceError::BadRequest(e.to_string()))?; let user = self.user_repository.find_by_id(&user_id).await?; Ok(user.map(|u| UserResponse { id: u.id().to_string(), email: u.email().to_string(), username: u.username().to_string(), })) } }
api/handlers/user_handler.rs
(Axum Handler):
use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use std::sync::Arc; use crate::{ api::dtos::{CreateUserRequest, UserResponse}, application::services::user_app_service::UserApplicationService, domain::repositories::UserRepository, infrastructure::error::ServiceError, }; // Define our AppState to hold shared dependencies #[derive(Clone)] pub struct AppState<R: UserRepository> { pub user_app_service: Arc<UserApplicationService<R>>, } pub async fn create_user_handler<R: UserRepository>( State(app_state): State<AppState<R>>, Json(request): Json<CreateUserRequest>, ) -> Result<Json<UserResponse>, ServiceError> { let response = app_state.user_app_service.create_user(request).await?; Ok(Json(response)) } pub async fn get_user_handler<R: UserRepository>( State(app_state): State<AppState<R>>, Path(user_id): Path<String>, ) -> Result<(StatusCode, Json<UserResponse>), ServiceError> { if let Some(user_response) = app_state.user_app_service.get_user_by_id(&user_id).await? { Ok((StatusCode::OK, Json(user_response))) } else { Err(ServiceError::NotFound("User not found".to_string())) } }
main.rs
(Assembles the application):
use axum::{routing::{get, post}, Router}; use std::{net::SocketAddr, sync::Arc}; use sqlx::PgPool; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod api; mod application; mod domain; mod infrastructure; use infrastructure::{ config::Config, error::ServiceError, persistence::user_repository_impl::PgUserRepository, }; use api::handlers::{ user_handler::{self, AppState}, }; use application::services::user_app_service::UserApplicationService; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "user_service=debug,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); let config = Config::load_from_env(); let pool = PgPool::connect(&config.database_url).await?; // Run database migrations sqlx::migrate!("./migrations") .run(&pool) .await .map_err(|e| ServiceError::DatabaseError(format!("Migration failed: {}", e)))?; // Instantiate repositories and services let user_repo = Arc::new(PgUserRepository::new(pool.clone())); let user_app_service = Arc::new(UserApplicationService::new(user_repo.clone())); let app_state = AppState { user_app_service, }; let app = Router::new() .route("/users", post(user_handler::create_user_handler::<PgUserRepository>)) .route("/users/:user_id", get(user_handler::get_user_handler::<PgUserRepository>)) .with_state(app_state.clone()); // Pass the state to the router let addr = SocketAddr::from(([127, 0, 0, 1], config.port)); tracing::info!("listening on {}", addr); axum::Server::bind(&addr) .serve(app.into_make_service()) .await?; Ok(()) }
This structure clearly delineates responsibilities:
- The
domain
layer is pure Rust, focusing on business rules and data models, entirely independent of Axum/Actix Web orsqlx
. - The
infrastructure
layer handles specific technologies (e.g.,sqlx::PgPool
). - The
application
layer coordinatesdomain
andinfrastructure
components to execute use cases. - The
api
layer deals with HTTP requests and responses, translating between generic web formats and application-specific inputs/outputs. main.rs
is responsible for composition and startup.
3. Dependency Injection and Traits for Decoupling
Notice how UserApplicationService
and user_handler
are generic over R: UserRepository
. This is a powerful Rust pattern for dependency injection. Instead of directly instantiating PgUserRepository
within UserApplicationService
, we express a dependency on any type that implements the UserRepository
trait.
This provides several benefits:
- Testability: In unit tests for
UserApplicationService
, you can provide a mock or fakeUserRepository
implementation, bypassing actual database calls. - Flexibility: You can easily switch out the database implementation (e.g., from PostgreSQL to MongoDB) by creating a new
UserRepository
implementation without changingUserApplicationService
logic. - Decoupling: Layers only depend on traits (abstractions), not concrete implementations, promoting loose coupling.
4. Error Handling Strategy
A centralized error handling strategy is crucial in large applications. Define a custom ServiceError
enum in your infrastructure/error.rs
that encapsulates various types of errors your application might encounter (e.g., DatabaseError
, ValidationError
, NotFound
, Unauthorized
). Implement From
conversions for common error types (like sqlx::Error
or validation errors) into your ServiceError
.
For Axum, you can implement the IntoResponse
trait for your ServiceError
enum to automatically convert errors into appropriate HTTP responses.
// infrastructure/error.rs use axum::response::{IntoResponse, Response}; use axum::http::StatusCode; #[derive(Debug)] pub enum ServiceError { NotFound(String), BadRequest(String), Unauthorized(String), Conflict(String), DatabaseError(String), InternalServerError(String), // ... possibly more specific errors } impl IntoResponse for ServiceError { fn into_response(self) -> Response { let (status, error_message) = match self { ServiceError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), ServiceError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), ServiceError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), ServiceError::Conflict(msg) => (StatusCode::CONFLICT, msg), ServiceError::DatabaseError(msg) => { tracing::error!("Database error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) }, ServiceError::InternalServerError(msg) => { tracing::error!("Internal server error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string()) }, }; // You might want a structured error payload for the client let body = serde_json::json!({ "error": error_message, }); (status, axum::Json(body)).into_response() } } // Implement From conversions for convenience impl From<sqlx::Error> for ServiceError { fn from(err: sqlx::Error) -> Self { ServiceError::DatabaseError(err.to_string()) } } // ... other `From` implementations for common error types
Conclusion
Effective modular design is foundational for building scalable, maintainable, and collaborative Rust web applications, especially with frameworks like Actix Web and Axum. By diligently applying Rust's workspace and module system, adhering to layered architectures, and leveraging traits for dependency inversion, developers can create robust systems where each component owns a clear, isolated responsibility. This deliberate organization significantly enhances code clarity, testability, and the ability to adapt to evolving requirements, ensuring your project thrives as it grows in size and complexity. Modular design is the compass that guides large Rust web projects through the intricate landscape of software development.