Building an API with Rust and Axum – Part 2: Architecture of Rust, Axum, and SeaORM


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.

🧱 High-Level Architecture Overview #

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).

Folder Structure #

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.

Response DTO Structure #

#[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,
        }
    }
}

Command Handler Trait #

#[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>,
}

Handler Implementation #

#[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))
    }
}

⚠️ Disclaimer: About the migrations Folder #

🧩 The migrations folder contains SeaORM-generated entity models and migration scripts. It's essential to:
  • Generate your own database schema and entities using SeaORM CLI tools, tailored to your project's needs.
  • Use this folder as a reference, not a direct template.
  • Align entity definitions with your specific database tables by running:
sea-orm-cli generate entity -u postgres://<your-database-url> -o migrations
  • Then, customize the generated entities to match your application's domain logic.

🚨 Do not rely on pre-built models blindly — your schema might differ significantly. Use this only as a structural guideline.

🧭 Clean Architecture & CQRS Design Philosophy #

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.

✨ Benefits of This Approach #

  • Separation of Concerns: Presentation, application logic, and infrastructure are clearly separated.
  • Scalability: Adding new features or modules can be done independently without affecting other parts.
  • Testability: Business logic is isolated in the application_core layer, making it easy to write unit tests.
  • Maintainability: Each module has a clear purpose, and folder structure reinforces discipline.

This clean separation makes the system easier to reason about and evolve as requirements change.

🏋️Enhancement: Introduce Data Access Service (DAS) Layer #

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).

Why Introduce DAS? #

  • Separation of Concerns: Handlers should focus on business logic, not database connectivity.
  • Scalability: Easier to manage and test individual database interactions.
  • Maintainability: Clearer abstraction between database layer and application logic.

Steps to Enhance the Architecture #

  1. Create Data Access Service (DAS)
    • DAS will encapsulate all database operations.
    • It will handle the lifecycle of the DbConnection, offering methods that the handlers can use to interact with the database without directly managing the connection.
  2. Refactor Handlers to Use DAS
    • Instead of injecting the DbConnection directly into the handlers, inject the DAS.
    • The DAS will then provide methods for database operations.

Example: Creating a Data Access Service (DAS) #

  1. Create DAS Interface
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
}
  1. Implement DAS
#[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())
    }
}
  1. Refactor Handlers to Use DAS
#[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
    }
}

Benefits of Introducing DAS #

  • Centralized Database Logic: All database-related tasks are now encapsulated in the DAS, providing a single place to modify how database queries are executed.
  • Testability: You can now mock the DAS layer in tests, making it easier to isolate and test the business logic in handlers.
  • Decoupling: Handlers are no longer tightly coupled with the database, making them more flexible and easier to maintain.

=> This enhancement improves the architecture by following the Single Responsibility Principle (SRP), promoting better testability, and enabling easier maintenance of the application.

📚 References and Further Reading #

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.

By [email protected]

Published on 4/11/2025