Skip to content
rusthacks.dev
Architecture

Hexagonal Architecture in Rust With Traits as Ports

Put the business rules in the middle, describe what they need as traits (ports) and plug in databases and HTTP as adapters. A complete small example with a test double.

3 min read
Summarize in AI
Flat illustration for the post “Hexagonal Architecture in Rust With Traits as Ports”, composed around generics.

The test for hexagonal architecture is simple: can you run your business rules in a test without a database? If the answer is no, the rules know too much about storage. The pattern fixes it by making the domain define what it needs, as a trait, and letting the outside world implement it. Rust's traits fit this closely. This post builds a minimal example. It is one of the boundaries in organizing Rust projects.

Rust 1.90, edition 2024.

The domain and its port

rust
// domain.rs: no framework, no driver
#[derive(Debug, Clone, PartialEq)]
pub struct User { pub id: u64, pub email: String }

#[derive(Debug, PartialEq)]
pub enum SignupError { EmailTaken, InvalidEmail }

pub trait UserRepository {
    fn find_by_email(&self, email: &str) -> Option<User>;
    fn insert(&self, email: &str) -> User;
}

pub struct Signup<R: UserRepository> {
    repo: R,
}

impl<R: UserRepository> Signup<R> {
    pub fn new(repo: R) -> Self { Signup { repo } }

    pub fn register(&self, email: &str) -> Result<User, SignupError> {
        if !email.contains('@') {
            return Err(SignupError::InvalidEmail);
        }
        if self.repo.find_by_email(email).is_some() {
            return Err(SignupError::EmailTaken);
        }
        Ok(self.repo.insert(email))
    }
}

register holds the rules: valid email, no duplicates. It knows UserRepository, not PostgreSQL. A real port would be async and fallible; they are synchronous here to keep the shape visible.

An adapter for tests

rust
// adapters/memory.rs
use std::sync::Mutex;
use crate::domain::{User, UserRepository};

#[derive(Default)]
pub struct InMemoryUsers(Mutex<Vec<User>>);

impl UserRepository for InMemoryUsers {
    fn find_by_email(&self, email: &str) -> Option<User> {
        self.0.lock().unwrap().iter().find(|u| u.email == email).cloned()
    }
    fn insert(&self, email: &str) -> User {
        let mut users = self.0.lock().unwrap();
        let user = User { id: users.len() as u64 + 1, email: email.to_string() };
        users.push(user.clone());
        user
    }
}

The test:

rust
#[test]
fn rejects_duplicates() {
    let signup = Signup::new(InMemoryUsers::default());
    signup.register("[email protected]").unwrap();
    assert_eq!(signup.register("[email protected]"), Err(SignupError::EmailTaken));
}

It runs in microseconds with no setup. The production adapter implements the same trait over SQLx; the HTTP handler constructs Signup::new(PgUsers::new(pool)) once at startup.

Generics or trait objects

Signup<R> with a generic parameter compiles to direct calls and the repository type is part of the type. If many services must be wired at run time, Arc<dyn UserRepository> hides the type, at the price of dyn compatibility rules: see static versus dynamic dispatch. The mechanics of inverting the dependency are in dependency inversion with traits.

Where to put the files

In a workspace: domain has no dependencies beyond std and perhaps thiserror; adapters depends on domain; the binary wires them. Even in one crate, keep domain free of use sqlx and use axum, and enforce it in review or with a lint on use paths.

Error conversion

The domain returns its own error enum. The adapter maps driver errors into it, and the web layer maps it to a status code. Each arrow is a From impl: error boundaries between layers.

When it is too much

A CRUD service where every operation is a database call with a validation has no rules to protect. A trait per table adds ceremony and nothing else. Start with plain functions, and extract a port when a second implementation or a slow test appears. Related decisions: clean architecture in Rust and domain driven design. Alistair Cockburn's original description is at alistair.cockburn.us.

Also inGenerics
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.