Implementing clean architecture in Rust. A guide to backend project structure

If you have ever built a backend service where your business logic was tangled up with your database queries and HTTP handlers, you know the pain of tight coupling. At first, it feels productive. You write a handler, query a database directly, and return a JSON response.

But as the codebase grows, this approach breaks down. Testing becomes a nightmare because you can’t test your business rules without spinning up a live database and a mock web server. Swapping out a dependency or upgrading a major framework version requires rewriting core logic.

This is where applying clean architecture in Rust becomes highly valuable. By adopting concepts from Rust domain-driven-design (DDD) and separating your code into distinct layers, you can build a Rust backend project structure that is highly testable, maintainable and completely agnostic to the framework you choose to run it.

The dependency rule

The fundamental rule of clean architecture is that dependencies must always point inward. The innermost layers should know absolutely nothing about the outermost layers. Your core business rules should not know if they are being triggered by an HTTP request, a CLI command or a background worker. They shouldn’t care if data is stored in PostgreSQL, Redis or a flat file.

To achieve this in Rust, we typically divide the project into three distinct layers:

  1. Domain: The core entities and business rules.
  2. Application: Use cases that orchestrate the domain logic and define interfaces (traits) for external systems.
  3. Infrastructure: The concrete implementations for databases, web servers and third-party APIs.

Before diving in, here is the directory layout we are building toward:

src/
├── main.rs
├── domain/
│   ├── mod.rs
│   └── subscription.rs
├── application/
│   ├── mod.rs
│   ├── ports.rs
│   └── use_cases.rs
└── infrastructure/
    ├── mod.rs
    ├── database/
    │   ├── mod.rs
    │   └── postgres_repository.rs
    └── web/
        ├── mod.rs
        └── handler.rs

Let’s walk through how to implement these layers using a simple example: subscribing a user to a newsletter.

The domain layer

The domain layer is pure Rust. It contains no external dependencies, no database drivers, and no web frameworks. It only contains the data structures and the rules that govern them.

// domain/error.rs
use thiserror::Error;

#[derive(Debug, Error)]
pub enum DomainError {
    #[error("invalid email format: {0}")]
    InvalidEmail(String),
}
// domain/subscription.rs

#[derive(Debug, Clone)]
pub struct Subscription {
    pub email: String,
    pub is_active: bool,
}

impl Subscription {
    pub fn new(email: String) -> Result<Self, DomainError> {
        if !email.contains('@') {
            return Err(DomainError::InvalidEmail(email));
        }
    
        Ok(Self {
            email,
            is_active: true,
        })
    }
}

This code is trivial to test. You don’t need a database to verify that the email validation works.

The application layer

The application layer contains your “use cases”. This layer orchestrates the flow of data to and from the domain entities.

Crucially, this is where we define how we want to interact with the outside world using traits. In clean architecture, this is known as defining “ports”.

// application/error.rs
use thiserror::Error;
use crate::domain::error::DomainError;

#[derive(Debug, Error)]
pub enum AppError {
    #[error(transparent)]
    Domain(#[from] DomainError),
    #[error("email already subscribed")]
    AlreadySubscribed,
    #[error("repository failure: {0}")]
    Repository(String),
}
// application/ports.rs
use crate::domain::subscription::Subscription;
use crate::application::error::AppError;
use async_trait::async_trait;

#[async_trait]
pub trait SubscriptionRepository: Send + Sync {
    async fn save(&self, subscription: &Subscription) -> Result<(), AppError>;
    async fn exists(&self, email: &str) -> Result<bool, AppError>;
}

You might wonder why we use async_trait when Rust 1.75+ supports async fn in traits natively. The reason is dynamic dispatch: native async trait methods aren’t dyn-compatible, so they can’t be used behind Arc<dyn SubscriptionRepository>. Since dependency injection via trait objects is the whole point here, async_trait is still the right tool.

Next, we write the use case itself. We use dependency injection to pass in anything that implements our SubscriptionRepository trait.

// application/use_cases.rs
use std::sync::Arc;
use crate::domain::subscription::Subscription;
use crate::application::ports::SubscriptionRepository;
use crate::application::error::AppError;

pub struct SubscribeUser {
    repository: Arc<dyn SubscriptionRepository>,
}

impl SubscribeUser {
    pub fn new(repository: Arc<dyn SubscriptionRepository>) -> Self {
        Self { repository }
    }

    pub async fn execute(&self, email: String) -> Result<(), AppError> {
        if self.repository.exists(&email).await? {
            return Err(AppError::AlreadySubscribed);
        }

        let subscription = Subscription::new(email)?;
        self.repository.save(&subscription).await?;

        Ok(())
    }
}

By using Arc<dyn SubscriptionRepository>, we achieve dynamic dispatch, allowing us to easily inject a mock repository for unit testing without compiling against an actual database.

// application/use_cases.rs  (inside #[cfg(test)] mod tests)
#[cfg(test)]
mod tests {
    use super::*;
    use async_trait::async_trait;
    use std::sync::Mutex;

    // A fake repo that records saves in memory — no database involved.
    struct InMemoryRepo {
        existing: bool,
        saved: Mutex<Vec<Subscription>>,
    }

    #[async_trait]
    impl SubscriptionRepository for InMemoryRepo {
        async fn save(&self, sub: &Subscription) -> Result<(), AppError> {
            self.saved.lock().unwrap().push(sub.clone());
            Ok(())
        }
        async fn exists(&self, _email: &str) -> Result<bool, AppError> {
            Ok(self.existing)
        }
    }

    #[tokio::test]
    async fn subscribes_a_new_user() {
        let repo = Arc::new(InMemoryRepo { existing: false, saved: Mutex::new(vec![]) });
        let use_case = SubscribeUser::new(repo.clone());

        let result = use_case.execute("[email protected]".to_string()).await;

        assert!(result.is_ok());
        assert_eq!(repo.saved.lock().unwrap().len(), 1);
    }

    #[tokio::test]
    async fn rejects_duplicate_email() {
        let repo = Arc::new(InMemoryRepo { existing: true, saved: Mutex::new(vec![]) });
        let use_case = SubscribeUser::new(repo);

        let result = use_case.execute("[email protected]".to_string()).await;

        assert!(result.is_err()); // "Email already subscribed"
    }
}

This test runs in milliseconds, needs no Postgres, no Docker, no web server.

The infrastructure layer

The infrastructure layer is the outermost ring. This is where your code interacts with the messy outside world. Here, we implement the traits defined in the application layer and wire up our web server.

The database implementation

// infrastructure/database/postgres_repository.rs
use crate::domain::subscription::Subscription;
use crate::application::ports::SubscriptionRepository;
use crate::application::error::AppError;
use async_trait::async_trait;
use sqlx::PgPool;

pub struct PostgresSubscriptionRepository {
    pool: PgPool,
}

impl PostgresSubscriptionRepository {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl SubscriptionRepository for PostgresSubscriptionRepository {
    async fn save(&self, subscription: &Subscription) -> Result<(), AppError> {
        // sqlx query execution goes here
        Ok(())
    }
    async fn exists(&self, email:&str) -> Result<bool, AppError> {
        // sqlx query execution goes here
        Ok(false)
    }
}

The web server

For the HTTP delivery mechanism, we will use the actix-web framework. The handler extracts the request data, grabs the use case from the application state, and executes it.

Notice how the Actix handler knows nothing about SQL or the database pool. It only knows about the SubscribeUser use case.

// infrastructure/web/handler.rs
use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
use crate::application::use_cases::SubscribeUser;

#[derive(Deserialize)]
pub struct SubscribeRequest {
    email:String,
}

pub async fn subscribe_handler (
    req: web::Json<SubscribeRequest>,
    use_case: web::Data<SubscribeUser>) -> impl Responder {
    match use_case.execute(req.email.clone()).await {
        Ok(_) => HttpResponse::Ok().body("Subscribed successfully"),
        Err(e) => HttpResponse::BadRequest().body(e.to_string()),
    }
}

Tying it all together

Finally, in your main.rs, you construct the application graph. You instantiate the database pool, wrap it in the repository implementation, inject the repository into the use case, and pass the use case into the Actix-web server state.

// main.rs
use actix_web::{web, App, HttpServer};
use std::sync::Arc;
use sqlx::postgres::PgPoolOptions;

mod domain;
mod application;
mod infrastructure;

use crate::infrastructure::database::postgres_repository::PostgresSubscriptionRepository;
use crate::infrastructure::web::handler::subscribe_handler;
use crate::application::use_cases::SubscribeUser;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // 1. Initialize Infrastructure
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect("postgres://user:pass@localhost/db")
        .await
        .unwrap();
    let repository = Arc::new(PostgresSubscriptionRepository::new(pool));

    // 2. Initialize the Application Use Cases
    let subscribe_use_case = SubscribeUser::new(repository);
    let use_case_data = web::Data::new(subscribe_use_case);

    // 3. Start Web Server
    HttpServer::new(move || {
        App::new()
            .app_data(use_case_data.clone())
            .route("/subscribe", web::post().to(subscribe_handler))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Note that repository is an Arc<PostgresSubcriptionRepository>, but SubscribeUser::new wants Arc<dyn SubscriptionRepository>. Rust’s unsized coercion converts the concrete Arc into the trait-object Arc automatically, so no explicit cast is needed.

Conclusion

Structuring a Rust backend project using clean architecture requires a bit more boilerplate upfront. You have to define traits, manage Arc pointers and explicitly map data across boundaries.

However, the payoff is immediate as soon as the project grows. Your domain logic remains isolated and pristine. You can write fast unit tests for your core business rules without spinning up Docker containers for your database. And if you ever need to swap out a technology, the refactoring is contained entirely within the infrastructure layer.

Find more updates on the Rust ecosystem and modern software architecture at Rust-Stack