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.

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
// 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
// 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:
#[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.


