Rust and Hexagonal Architecture: Clean Code Without Compromises

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Rust and Hexagonal Architecture: Writing Code That Isn't Scary to Change

\n\n

Every 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\n

The 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\n

What is Hexagonal Architecture? (Very Briefly)

\n\n

Imagine 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\n

Key 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\n

In 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\n

Rust Project Structure

\n\n

To start, let's create a folder structure that visually separates the domain from the infrastructure:

\n\n
src/\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\n

Step 1: Domain — The Heart of the System

\n\n

Let'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\n

Now 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\n

Important: We use Box<dyn UserRepository> for dynamic dispatch. In Rust, this is normal for complex applications. For simple cases, generics can be used.

\n\n

Step 2: Ports — Contracts for the External

Blogs

Book Recommendations