Skip to content

Hexagonal architecture in Rust: A practical guide

27 August 2026

Ever built a Rust application that started simple, but as features grew, your code became a tangled mess of database calls, HTTP handlers, and business logic all jumbled together? If so, hexagonal architecture (also known as ports and adapters) can help you keep your core logic clean, testable, and independent of external frameworks.

In this article, we’ll walk through hexagonal architecture step by step in Rust. You’ll learn how to structure a project, define clear boundaries, and write code that’s easy to maintain and evolve. Whether you’re building a CLI tool, a web service, or something else entirely, these patterns will serve you well.


What is Hexagonal Architecture? ​

Imagine your application as a core business brain 🧠 surrounded by a protective shell. The brain only knows about ports – abstract interfaces that define what it needs from the outside world (like “save an account”) but not how that happens.

The outside world (databases, web frameworks, message queues) plugs into those ports via adapters. Each adapter is a concrete implementation of a port.

Benefits:

  • Testability – replace a real database with an in-memory fake in tests.
  • Flexibility – swap SQLite for PostgreSQL without touching business logic.
  • Maintainability – core logic has no idea about HTTP, SQL, or file systems.
  • Parallel development – teams can work on adapters independently.

Project Structure ​

In Rust, we can mirror the architecture with a Cargo workspace. A typical layout:

text
my-app/
├── Cargo.toml            # workspace definition
├── domain/               # pure business logic and entities
│   ├── src/
│   │   ├── lib.rs
│   │   ├── account.rs
│   │   └── error.rs
├── application/          # use‑case orchestration
│   ├── src/
│   │   ├── lib.rs
│   │   └── services.rs
├── adapters/             # concrete implementations of ports
│   ├── sqlite/
│   │   ├── src/lib.rs
│   ├── http/
│   │   ├── src/lib.rs
├── infrastructure/       # binary entry points, composition root
│   ├── src/
│   │   └── main.rs

Each crate has a clear responsibility:

  • domain – zero dependencies on external crates (except maybe thiserror or regex).
  • application – depends only on domain and defines the ports (traits).
  • adapters – depend on application (to implement ports) and external crates.
  • infrastructure – depends on all, wires everything together.

1. Domain Layer: The Core Business Logic ​

The domain layer contains entities and business rules. These are plain Rust structs and methods with no I/O, no framework, no database calls. This makes them extremely easy to unit test.

Let’s define an Account entity that can add money, but only if it’s active.

rust
// domain/src/account.rs

use crate::error::DomainError;

#[derive(Debug, Clone, PartialEq)]
pub struct Account {
    pub id: String,
    pub balance: i64,
    pub active: bool,
}

impl Account {
    pub fn new(id: impl Into<String>, balance: i64, active: bool) -> Self {
        Self {
            id: id.into(),
            balance,
            active,
        }
    }

    /// Adds money to the account if it is active.
    /// Returns an error if the account is locked.
    pub fn add_money(&mut self, amount: i64) -> Result<(), DomainError> {
        if !self.active {
            return Err(DomainError::AccountLocked);
        }
        self.balance += amount;
        Ok(())
    }
}

Key points:

  • add_money takes &mut self because it modifies the balance.
  • The method returns a Result with a domain-specific error.
  • No external types – only pure Rust.

We define custom errors using thiserror for nicer ergonomics:

rust
// domain/src/error.rs

use thiserror::Error;

#[derive(Debug, Error, PartialEq)]
pub enum DomainError {
    #[error("account is locked")]
    AccountLocked,
    #[error("insufficient funds")]
    InsufficientFunds,
}

2. Ports (Contracts): Abstract Interfaces ​

Ports are traits that define the interactions your application needs from the outside world. There are two kinds:

  • Inbound ports – define use‑cases your application exposes to the outside (e.g., CreateAccount, DepositMoney).
  • Outbound ports – define what your application needs from external systems (e.g., AccountRepository, PaymentGateway).

In Rust, we express ports as traits. For asynchronous operations, we need to use the async_trait crate (or nightly async fn in traits) because native async traits are not yet stable.

Let’s define an outbound port for storing and retrieving accounts.

rust
// application/src/ports.rs

use async_trait::async_trait;
use domain::account::Account;
use domain::error::DomainError;

#[async_trait]
pub trait AccountRepository: Send + Sync {
    async fn save(&self, account: &Account) -> Result<(), DomainError>;
    async fn find_by_id(&self, id: &str) -> Result<Option<Account>, DomainError>;
}

Why Send + Sync? Because the adapter might be called from multiple threads, and we want to store it in an Arc.

We can also define an inbound port (use‑case interface) if we want to expose multiple implementations, but often the application service itself acts as the inbound port.


3. Application Services: Orchestrating Use Cases ​

A service combines one or more ports to implement a complete business use case. It contains no business rules itself – it delegates to domain entities and ports.

Example: a DepositMoney service that loads an account, calls add_money, and saves it back.

rust
// application/src/services.rs

use async_trait::async_trait;
use domain::account::Account;
use domain::error::DomainError;

use crate::ports::AccountRepository;

pub struct DepositMoney<R: AccountRepository> {
    repository: R,
}

impl<R: AccountRepository> DepositMoney<R> {
    pub fn new(repository: R) -> Self {
        Self { repository }
    }

    pub async fn execute(&self, account_id: &str, amount: i64) -> Result<Account, DomainError> {
        // Load
        let mut account = self
            .repository
            .find_by_id(account_id)
            .await?
            .ok_or(DomainError::AccountLocked)?; // Custom error for not found

        // Apply business logic
        account.add_money(amount)?;

        // Save
        self.repository.save(&account).await?;

        Ok(account)
    }
}

What we achieved:

  • The service depends only on the AccountRepository trait – not on any concrete database.
  • Testing this service with a mock repository is trivial.
  • We can easily swap repository implementations.

4. Adapters: Concrete Implementations ​

Adapters live outside the core and implement ports using specific technologies. We’ll create a SQLite adapter using the sqlx crate.

rust
// adapters/sqlite/src/lib.rs

use async_trait::async_trait;
use sqlx::sqlite::{SqlitePool, SqliteConnectOptions};
use std::str::FromStr;
use anyhow::Context;
use domain::account::Account;
use domain::error::DomainError;
use application::ports::AccountRepository;

#[derive(Clone)]
pub struct SqliteAccountRepository {
    pool: SqlitePool,
}

impl SqliteAccountRepository {
    pub async fn new(database_url: &str) -> anyhow::Result<Self> {
        let options = SqliteConnectOptions::from_str(database_url)
            .context("invalid database URL")?
            .create_if_missing(true);

        let pool = SqlitePool::connect_with(options)
            .await
            .context("failed to connect to database")?;

        // Run migrations (simplified)
        sqlx::query(
            "CREATE TABLE IF NOT EXISTS accounts (
                id TEXT PRIMARY KEY,
                balance INTEGER NOT NULL,
                active INTEGER NOT NULL
            )",
        )
        .execute(&pool)
        .await?;

        Ok(Self { pool })
    }
}

#[async_trait]
impl AccountRepository for SqliteAccountRepository {
    async fn save(&self, account: &Account) -> Result<(), DomainError> {
        sqlx::query(
            "INSERT INTO accounts (id, balance, active) VALUES (?, ?, ?)
             ON CONFLICT(id) DO UPDATE SET balance = excluded.balance, active = excluded.active",
        )
        .bind(&account.id)
        .bind(account.balance)
        .bind(account.active as i64)
        .execute(&self.pool)
        .await
        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
        Ok(())
    }

    async fn find_by_id(&self, id: &str) -> Result<Option<Account>, DomainError> {
        let row = sqlx::query_as::<_, (String, i64, i64)>(
            "SELECT id, balance, active FROM accounts WHERE id = ?",
        )
        .bind(id)
        .fetch_optional(&self.pool)
        .await
        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;

        Ok(row.map(|(id, balance, active)| Account::new(id, balance, active != 0)))
    }
}

Note: We map SQL errors to DomainError, keeping the domain unaware of SQL details. We also added a DatabaseError variant to DomainError (not shown) – a common pattern.


5. Inbound Adapters: HTTP Example ​

So far we’ve only dealt with the outbound side. Let’s create a simple HTTP handler (using axum) that exposes the deposit use case.

rust
// adapters/http/src/lib.rs

use axum::{extract::State, routing::post, Json, Router};
use application::services::DepositMoney;
use domain::account::Account;
use std::sync::Arc;

pub struct AppState<R: AccountRepository> {
    pub deposit_service: DepositMoney<R>,
}

pub fn build_router<R: AccountRepository + Send + Sync + 'static>(
    state: Arc<AppState<R>>,
) -> Router {
    Router::new()
        .route("/deposit", post(deposit_handler))
        .with_state(state)
}

async fn deposit_handler<R: AccountRepository>(
    State(state): State<Arc<AppState<R>>>,
    Json(payload): Json<DepositRequest>,
) -> Result<Json<Account>, String> {
    let account = state
        .deposit_service
        .execute(&payload.account_id, payload.amount)
        .await
        .map_err(|e| e.to_string())?;
    Ok(Json(account))
}

#[derive(serde::Deserialize)]
struct DepositRequest {
    account_id: String,
    amount: i64,
}

The handler knows nothing about business rules; it merely calls the service and translates HTTP to domain types.


6. Infrastructure: Composition Root ​

Finally, we wire everything together in main.rs. This is the only place that knows about concrete implementations.

rust
// infrastructure/src/main.rs

use application::services::DepositMoney;
use adapters::sqlite::SqliteAccountRepository;
use adapters::http::{build_router, AppState};
use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let database_url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "sqlite://myapp.db".to_string());

    // Adapter
    let repo = SqliteAccountRepository::new(&database_url).await?;

    // Application service
    let deposit_service = DepositMoney::new(repo);

    // Inbound adapter
    let state = Arc::new(AppState { deposit_service });
    let router = build_router(state);

    // Start server
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    println!("Listening on {}", listener.local_addr()?);
    axum::serve(listener, router).await?;

    Ok(())
}

Now you can run the server, send POST /deposit with JSON body {"account_id": "123", "amount": 100}, and the money will be deposited via the full hexagonal pipeline.


Testing with In-Memory Adapters ​

One of the greatest advantages is testing. You can implement the same port with an in-memory vector:

rust
// tests/in_memory_repo.rs

use std::collections::HashMap;
use async_trait::async_trait;
use application::ports::AccountRepository;
use domain::account::Account;
use domain::error::DomainError;

pub struct InMemoryAccountRepository {
    accounts: Mutex<HashMap<String, Account>>,
}

#[async_trait]
impl AccountRepository for InMemoryAccountRepository {
    async fn save(&self, account: &Account) -> Result<(), DomainError> {
        self.accounts.lock().unwrap().insert(account.id.clone(), account.clone());
        Ok(())
    }

    async fn find_by_id(&self, id: &str) -> Result<Option<Account>, DomainError> {
        Ok(self.accounts.lock().unwrap().get(id).cloned())
    }
}

Then write a unit test for the DepositMoney service using this fake – no database needed, fast and deterministic.


Conclusion ​

Hexagonal architecture may seem like extra boilerplate at first, but the payoff is enormous: your business logic becomes a pure, testable core, and you can swap out databases, web frameworks, or even entire external services with minimal changes.

Rust’s trait system makes implementing ports and adapters remarkably natural. By separating concerns at the crate level, you also enforce architectural boundaries at compile time – the compiler will remind you if you accidentally introduce a dependency from domain to sqlx.

Start small. Pick one feature, define its domain entity, write a trait for the repository, implement an in-memory adapter for testing, and then add a real adapter later. You’ll quickly see how this pattern scales with your application’s complexity.

Happy architecting! 🦀

All views expressed are my own