Rust and Hexagonal Architecture: Writing Code That Isn't Scary to Change
\n\nEvery developer has at least once encountered a situation where a small change in the database or switching an HTTP framework turned into a nightmare of refactoring. Business logic gets tangled with implementation details, and the code turns into "spaghetti." For Rust, with its focus on safety and performance, such mixing is a real crime.
\n\nThe solution to this problem is Hexagonal Architecture (also known as Ports and Adapters). In this article, we will break down how to apply this pattern in Rust, leveraging the power of the type system and traits. You will learn how to isolate domain logic from the outside world, write testable code, and easily swap database drivers or web servers. This is an essential skill for anyone who wants to build complex, maintainable systems in Rust.
\n\nWhat is Hexagonal Architecture? (Very Briefly)
\n\nImagine a hexagon. In the center is your business logic (the domain). On the edges are "ports" (interfaces) through which the domain communicates with the outside world. And on the outside are "adapters" (concrete implementations) that connect to the ports.
\n\nKey idea: The domain knows nothing about how data is saved or requests are sent. It only knows about ports — traits with methods. Adapters (e.g., a PostgreSQL repository or an HTTP controller) implement these traits.
\n\nIn Rust, this concept fits perfectly thanks to traits (analogous to interfaces) and generics. The compiler checks all connections at compile time, not at runtime.
\n\nRust Project Structure
\n\nTo start, let's create a folder structure that visually separates the domain from the infrastructure:
\n\nsrc/\n├── domain/ # Pure business logic (no dependencies!)\n│ ├── mod.rs\n│ ├── models.rs # Entities and Value Objects\n│ └── services.rs # Services working with ports\n├── ports/ # Traits (interfaces) for the outside world\n│ ├── mod.rs\n│ └── repositories.rs\n├── adapters/ # Implementations of ports (databases, HTTP, etc.)\n│ ├── mod.rs\n│ ├── postgres_repo.rs\n│ └── in_memory_repo.rs # For tests\n├── main.rs # Application assembly (Wiring)\n└── lib.rs # Public API of the library\n\nStep 1: Domain — The Heart of the System
\n\nLet's start with a simple user model. The domain has no dependencies on external libraries (not even serde, unless required by business logic).
\n\n// src/domain/models.rs\npub struct User {\n pub id: String,\n pub name: String,\n pub email: String,\n}\n\nimpl User {\n pub fn new(id: String, name: String, email: String) -> Self {\n // Validation can go here\n Self { id, name, email }\n }\n}\n\nNow let's define a domain service. It will work with users but won't know where they come from.
\n\n// src/domain/services.rs\nuse crate::ports::repositories::UserRepository;\n\npub struct UserService {\n repo: Box<dyn UserRepository>,\n}\n\nimpl UserService {\n pub fn new(repo: Box<dyn UserRepository>) -> Self {\n Self { repo }\n }\n\n pub fn create_user(&mut self, name: String, email: String) -> Result<User, String> {\n let id = uuid::Uuid::new_v4().to_string(); // A dependency? Yes, but it's a detail\n let user = User::new(id, name, email);\n self.repo.save(&user)?;\n Ok(user)\n }\n\n pub fn get_user(&self, id: &str) -> Result<Option<User>, String> {\n self.repo.find_by_id(id)\n }\n}\n\nImportant: We use Box<dyn UserRepository> for dynamic dispatch. In Rust, this is normal for complex applications. For simple cases, generics can be used.