In this section, we delve into the architecture powering the API system built with Rust, Axum, and SeaORM. This structure emphasizes scalability, maintainability, and performance.
The system is organized into distinct modules:
application_core
: Contains core business logic and command handlers.migrations
: Houses SeaORM entity models and database migrations.src
: Includes the entry point (main.rs
) and presentation layer (Axum HTTP handlers).my-cms/ ├── application_core/ # Core business logic and services │ └── commands/ │ └── post/ │ └── read.rs # Post read command handler ├── migrations/ # SeaORM entity definitions and migrations ├── src/ │ ├── api/ # Axum routes and controllers │ └── bin/ │ └── main.rs # Application entry point ├── Cargo.toml # Workspace configuration └── ...
application_core/commands/post/read.rs
#This module encapsulates the business logic for handling post-related operations.
#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PostReadResponse { pub id: Uuid, pub title: String, pub preview_content: Option<String>, pub content: String, pub slug: String, pub thumbnail_paths: Vec<String>, pub published: bool, pub created_by: String, pub created_at: DateTimeWithTimeZone, pub last_modified_by: Option<String>, pub last_modified_at: Option<DateTimeWithTimeZone>, pub category_id: Uuid, pub row_version: i32, pub tags: Vec<tags::Model>, pub tag_names: Vec<String>, pub translations: Vec<post_translations::Model>, } impl PostReadResponse { pub fn new( post: Model, tags: Vec<tags::Model>, translations: Vec<post_translations::Model>, ) -> Self { let tag_names = tags .iter() .map(|tag| tag.name.to_owned()) .collect::<Vec<String>>(); Self { id: post.id, title: post.title, preview_content: post.preview_content, content: post.content, slug: post.slug, published: post.published, created_by: post.created_by, created_at: post.created_at, last_modified_by: post.last_modified_by, last_modified_at: post.last_modified_at, category_id: post.category_id, row_version: post.row_version, thumbnail_paths: post.thumbnail_paths, tags, tag_names, translations, } } }
#[async_trait] pub trait PostReadHandlerTrait { async fn handle_get_all_posts(&self) -> Result<Vec<PostReadResponse>, AppError>; async fn handle_get_posts_with_filtering( &self, category_type: Option<CategoryType>, published: Option<bool>, ) -> Result<Vec<PostReadResponse>, AppError>; async fn handle_get_post( &self, id: Uuid, ) -> Result<PostReadResponse, AppError>; } #[derive(Debug, Clone)] pub struct PostReadHandler { pub db: Arc<DatabaseConnection>, }
#[async_trait] impl PostReadHandlerTrait for PostReadHandler { #[instrument] async fn handle_get_all_posts(&self) -> Result<Vec<PostReadResponse>, AppError> { let db_result = Posts::find() .find_with_related(Tags) .all(self.db.as_ref()) .await .map_err(AppError::from)?; let response = db_result .into_iter() .map(|(post, tags)| { PostReadResponse::new(post, tags, vec![]) }) .collect(); Ok(response) } #[instrument] async fn handle_get_posts_with_filtering( &self, category_type: Option<CategoryType>, published: Option<bool>, ) -> Result<Vec<PostReadResponse>, AppError> { let db_result = Posts::find() .join(JoinType::LeftJoin, posts::Relation::Categories.def()) .apply_if(category_type, |query, v| { query.filter(Expr::col(categories::Column::CategoryType).eq(v.as_enum())) }) .apply_if(published, |query, v| { query.filter(Expr::col(posts::Column::Published).eq(v)) }) .find_with_related(Tags) .all(self.db.as_ref()) .await .map_err(AppError::from)?; let post_ids: Vec<Uuid> = db_result.iter().map(|(post, _)| post.id).collect(); let translations = post_translations::Entity::find() .filter(post_translations::Column::PostId.is_in(post_ids.clone())) .all(self.db.as_ref()) .await .map_err(AppError::from)?; let translations_map = translations.into_iter().fold( HashMap::new(), |mut acc: HashMap<Uuid, Vec<post_translations::Model>>, translation| { acc.entry(translation.post_id) .or_default() .push(translation); acc }, ); let response = db_result .into_iter() .map(|(post, tags)| { let post_translations = translations_map.get(&post.id).cloned().unwrap_or_default(); PostReadResponse::new(post, tags, post_translations) }) .collect(); Ok(response) } #[instrument] async fn handle_get_post(&self, id: Uuid) -> Result<PostReadResponse, AppError> { let post = Posts::find_by_id(id) .one(self.db.as_ref()) .await? .ok_or(AppError::NotFound)?; let tags = post.find_related(Tags).all(self.db.as_ref()).await?; let translations = post.find_related(post_translations::Entity) .all(self.db.as_ref()) .await?; Ok(PostReadResponse::new(post, tags, translations)) } }
migrations
Folder #🧩 The migrations
folder contains SeaORM-generated entity models and migration scripts. It's essential to:
sea-orm-cli generate entity -u postgres://<your-database-url> -o migrations
🚨 Do not rely on pre-built models blindly — your schema might differ significantly. Use this only as a structural guideline.
This project is architected using principles from Clean Architecture and Command Query Responsibility Segregation (CQRS). These paradigms promote the separation of concerns and the organization of code by responsibility.
application_core
layer, making it easy to write unit tests.This clean separation makes the system easier to reason about and evolve as requirements change.
Currently, in the architecture, DbConnection
is being injected directly into handlers for database tasks. While this approach is functional, it is not the best practice as it violates the Single Responsibility Principle (SRP) and makes the handlers tightly coupled with the database layer. To improve this and make the architecture more modular and maintainable, we can introduce a Data Access Service (DAS).
DbConnection
, offering methods that the handlers can use to interact with the database without directly managing the connection.DbConnection
directly into the handlers, inject the DAS.pub trait DataAccessService { fn get_post_by_id(&self, id: Uuid) -> impl Future<Output = Result<PostReadResponse, AppError>>; fn get_all_posts(&self) -> impl Future<Output = Result<Vec<PostReadResponse>, AppError>>; // Add other database-related methods here }
#[derive(Debug)] pub struct PostDataAccessService { pub db: Arc<DatabaseConnection>, } impl DataAccessService for PostDataAccessService { async fn get_post_by_id(&self, id: Uuid) -> Result<PostReadResponse, AppError> { let post = Posts::find_by_id(id) .find_with_related(Tags) .one(self.db.as_ref()) .await .map_err(|e| e.into())?; // Mapping db result to response format post.map(|p_and_tags| { PostReadResponse::new( p_and_tags.0.to_owned(), p_and_tags.1.to_owned(), vec![], ) }) .ok_or(AppError::NotFound) } async fn get_all_posts(&self) -> Result<Vec<PostReadResponse>, AppError> { let db_result = Posts::find() .find_with_related(Tags) .all(self.db.as_ref()) .await .map_err(|e| e.into())?; Ok(db_result .iter() .map(|p_and_tags| { PostReadResponse::new(p_and_tags.0.to_owned(), p_and_tags.1.to_owned(), vec![]) }) .collect()) } }
#[derive(Debug)] pub struct PostReadHandler { pub data_access_service: Arc<dyn DataAccessService>, } impl PostReadHandlerTrait for PostReadHandler { #[instrument] async fn handle_get_all_posts(&self) -> Result<Vec<PostReadResponse>, AppError> { self.data_access_service.get_all_posts().await } #[instrument] async fn handle_get_post(&self, id: Uuid) -> Result<PostReadResponse, AppError> { self.data_access_service.get_post_by_id(id).await } }
=> This enhancement improves the architecture by following the Single Responsibility Principle (SRP), promoting better testability, and enabling easier maintenance of the application.
If you're new to Clean Architecture or CQRS, here are some useful references tailored for Rust developers:
These resources dive deeper into architectural best practices and how to implement them effectively in Rust.
➡️ Check out the repository here: https://github.com/doitsu2014/my-cms
⬅️ Go back to Building an API with Rust and Axum – Part 1: Project Initialization & Setup
➡️ Continue to Part 3: Tracing & Observability → — Make your API transparent and easy to debug in production environments.
Published on 4/11/2025