Complete implementation guide for Rust projects using Hexagonal Architecture with vertical slicing. Use when working with Rust/Axum/PostgreSQL projects that require clean architecture principles, domain-driven design, or when implementing new features following strict architectural patterns. Essential for maintaining code quality and architectural consistency in backend projects.
Install
npx skillscat add oryjk/rust-hexagonal-architecture Install via the SkillsCat registry.
Rust Hexagonal Architecture
Complete architectural guidelines for building maintainable Rust backend applications using Hexagonal Architecture with vertical slicing.
Quick Start
When starting work on a Rust project using this architecture:
- Analyze the domain - identify business entities and their relationships
- Define the structure - create domain folders with vertical slicing
- Implement from core to edges: Domain → Ports → Application → Adapters
- Verify with
cargo checkafter every file edit - Document API changes in
docs/api.md
Architecture Overview
Core Principles
Hexagonal Architecture (Ports and Adapters) with Vertical Slicing:
- Business Logic isolated in the core domain layer
- External Dependencies handled by adapters
- Communication through port interfaces (traits)
- Domains organized vertically by business capability
Project Structure
src/
├── admin/ # Business domain (e.g., admin management)
│ ├── domain/ # Core business logic
│ │ ├── entities.rs
│ │ ├── enums.rs
│ │ └── mod.rs
│ ├── ports/ # Interface definitions
│ │ ├── repository.rs
│ │ └── mod.rs
│ ├── application/ # Use cases and orchestration
│ │ ├── actions.rs
│ │ └── mod.rs
│ └── adapters/ # External implementations
│ ├── web/ # HTTP handlers
│ │ ├── handlers.rs
│ │ ├── routes.rs
│ │ └── dto.rs
│ └── persistence/ # Database access
│ └── postgres_repository.rs
├── assets/
├── sync/
├── user/
└── bin/ # Migration scriptsLayer Responsibilities
Domain Layer (src/{domain}/domain/)
What it does:
- Define business entities (structs)
- Implement business logic and validation
- Define domain-specific enums and value objects
Constraints:
- ✅ Pure Rust - no external dependencies
- ✅ Can use
thiserrorfor error definitions - ❌ NO
sqlx,axum, or IO operations - ❌ NO database-specific code
Example:
// src/assets/domain/asset.rs
pub struct Asset {
pub code: String,
pub name: String,
pub asset_type: AssetType,
}
impl Asset {
pub fn new(code: String, name: String, asset_type: AssetType) -> Result<Self> {
if code.is_empty() {
return Err(DomainError::InvalidCode);
}
Ok(Self { code, name, asset_type })
}
}Ports Layer (src/{domain}/ports/)
What it does:
- Define repository interfaces (traits)
- Define service interfaces
- Establish contracts between layers
Constraints:
- ✅ Use
#[async_trait]for async methods - ✅ Require
Send + Syncbounds - ❌ NO implementation code
Example:
// src/assets/ports/repository.rs
#[async_trait]
pub trait AssetRepository: Send + Sync {
async fn find_by_code(&self, code: &str) -> Result<Option<Asset>>;
async fn save(&self, asset: &Asset) -> Result<Asset>;
async fn list(&self, pagination: &Pagination) -> Result<(Vec<Asset>, u64)>;
}Application Layer (src/{domain}/application/)
What it does:
- Implement use cases (orchestration)
- Coordinate multiple repositories
- Execute business workflows
Constraints:
- ✅ Call Port interfaces (not concrete implementations)
- ✅ Orchestrate business logic
- ❌ NO SQL queries
- ❌ NO direct database access
Example:
// src/assets/application/actions.rs
pub struct CreateAssetUseCase<R: AssetRepository> {
repository: R,
}
impl<R: AssetRepository> CreateAssetUseCase<R> {
pub async fn execute(&self, request: CreateAssetRequest) -> Result<Asset> {
// Business logic
let asset = Asset::new(request.code, request.name, request.asset_type)?;
// Persistence through port
self.repository.save(&asset).await
}
}Adapters Layer (src/{domain}/adapters/)
What it does:
- Implement port interfaces
- Handle external concerns (HTTP, Database)
- Map between DTOs and domain entities
Subdirectories:
web/- HTTP handlers, routes, DTOspersistence/- Database repositories, SQL queriesapi/- External API clients
Example:
// src/assets/adapters/persistence/postgres_repository.rs
#[derive(Clone)]
pub struct PostgresAssetRepository {
pool: PgPool,
}
#[async_trait]
impl AssetRepository for PostgresAssetRepository {
async fn find_by_code(&self, code: &str) -> Result<Option<Asset>> {
let row = sqlx::query_as::<_, (String, String, AssetType)>(
"SELECT code, name, asset_type FROM b_assets WHERE code = $1"
)
.bind(code)
.fetch_optional(&*self.pool)
.await?;
Ok(row.map(|(code, name, asset_type)| Asset { code, name, asset_type }))
}
}Critical Rules
1. Dependency Direction
Rule: Dependencies flow from outer to inner layers only.
Web Adapter → Application UseCase → Port Interface → Domain Entity
↑ ↓
└────────────── Persistence Adapter ────────┘Violations to avoid:
- ❌ Domain layer depending on Adapter
- ❌ Handler calling Repository directly (must use UseCase)
- ❌ UseCase containing SQL queries
2. Handler → UseCase → Repository Flow
Required flow for all HTTP requests:
HTTP Request
↓
Handler (parse request, validate HTTP)
↓
UseCase (business logic, orchestration)
↓
Repository (data access via Port interface)
↓
DatabaseHandler responsibilities:
- ✅ Parse HTTP request (Path, Query, Body)
- ✅ Validate HTTP-level constraints
- ✅ Convert DTO to UseCase request
- ✅ Call UseCase
- ✅ Convert error to HTTP status
- ❌ NO business logic
- ❌ NO direct database access
- ❌ NO repository calls
3. Generic vs Trait Object
Default preference: Use Arc<dyn Trait> over generics.
Use generics when:
- Performance-critical paths
- Need compile-time type safety
- ≤2-3 type parameters
Use trait objects when:
- Need flexibility
- Avoiding generic explosion (>2-3 type params)
- Type determined at runtime
Example:
// ✅ Preferred - trait object
pub struct AssetState {
create_asset_use_case: Arc<dyn CreateAssetUseCase>,
get_asset_use_case: Arc<dyn GetAssetUseCase>,
}
// ✅ Acceptable - generics (performance-critical)
pub struct GetAssetUseCase<R: AssetRepository> {
repository: Arc<R>,
}
// ❌ Avoid - generic explosion
pub struct ComplexUseCase<R1, R2, R3, R4, R5> { }4. Schema Changes and UseCase Modifications
Rule: Distinguish between structural changes and business logic changes when modifying database schemas.
Scenario A: Pure Structural Changes
Adding optional fields or changing types without affecting business logic (e.g., adding Option<f64> for display data).
| Layer | Modify? | Why? |
|---|---|---|
| Domain | ✅ Yes | Add/update field in entity |
| Ports | ❌ No | Interface unchanged |
| Application | ❌ No | Business logic unchanged (structural only) |
| Adapters | ✅ Yes | Update SQL + field mapping |
Example: Adding optional dividend_yield to Asset
// Domain: Add field
pub struct Asset {
pub code: String,
pub name: String,
pub dividend_yield: Option<f64>, // New optional field
}
// UseCase: No changes needed
pub async fn execute(&self, code: &str) -> Result<Asset> {
self.repository.find_by_code(code).await? // Logic unchanged
.ok_or_else(|| anyhow!("Asset not found"))
}Scenario B: Business Logic Changes
Adding fields that require new validation, workflows, or affect existing functionality.
| Layer | Modify? | Why? |
|---|---|---|
| Domain | ✅ Yes | Add/update field in entity |
| Ports | ❌ No | Interface unchanged |
| Application | ✅ Yes | Business logic changed |
| Adapters | ✅ Yes | Update SQL + field mapping |
Example: Adding dividend_yield with validation
// UseCase: Add validation logic
pub async fn execute(&self, code: &str) -> Result<Asset> {
let asset = self.repository.find_by_code(code).await?
.ok_or_else(|| anyhow!("Asset not found"))?;
// New business logic
if let Some(yield) = asset.dividend_yield {
if yield < 0.0 {
return Err(anyhow!("Dividend yield cannot be negative"));
}
}
Ok(asset)
}Key Principle: Separate structural changes from functional changes.
Coding Standards
Naming Conventions
Directories:
- Domain names: lowercase (e.g.,
admin,assets,user) - Use singular form (e.g.,
assetnotassets)
Database tables:
- Prefix:
b_(e.g.,b_assets,b_users) - Format: lowercase with underscores
Files:
- Entities:
entity_name.rs(e.g.,asset.rs) - UseCases:
actions.rsorverb_entity.rs - Repositories:
{database}_entity_repository.rs
Code:
- Structs/Enums:
PascalCase(e.g.,Asset,AssetType) - Functions:
snake_case(e.g.,find_by_code) - Constants:
SCREAMING_SNAKE_CASE
Error Handling
Layer-specific error handling:
- Domain: Define business errors with
thiserror - Repository: Map
sqlx::Errorto domain errors (don't expose DB errors) - Application/Web: Use
anyhow, map to HTTP status
// Domain layer
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DomainError {
#[error("Invalid asset code")]
InvalidCode,
#[error("Asset not found: {0}")]
NotFound(String),
}
// Application layer
use anyhow::Result;
pub async fn execute(&self, code: &str) -> Result<Asset> {
self.repository.find_by_code(code).await?
.ok_or_else(|| anyhow::anyhow!("Asset not found: {}", code))
}Database Standards
- All timestamp fields:
TIMESTAMPTZ(usechrono::DateTime<Utc>) - Use parameterized queries:
$1,$2 - Transactions for multi-step operations
- All tables start with
b_prefix
API Documentation
Critical: When adding/modifying APIs, MUST update docs/api.md
Include:
- HTTP method and path
- Request parameters (path, query, body)
- Response format (success and error)
- Field descriptions and examples
Development Workflow
Mandatory Checklist
- ✅ Analyze domain → define ports → implement logic → create adapters
- ✅ Run
cargo checkafter every file edit - ✅ Update
docs/api.mdif API changes - ✅ Commit only after all checks pass
Validation Commands
# Required after every file edit
cargo check
# Before committing
cargo build
cargo clippy
cargo test
# Update documentation
# docs/api.mdAnti-Patterns to Avoid
❌ Never do these:
- Handler calling Repository directly
- Domain struct with
#[derive(sqlx::FromRow)] - Using
infrastructurefolder name (useadapters) - Generic parameter explosion (>2-3 type params)
✅ Always do these:
- Handler → UseCase → Repository flow
- Domain = pure Rust (no sqlx, axum, IO)
- Use
Arc<dyn Trait>for Handler State - Distinguish between structural and business logic changes
Reference Documentation
For detailed architectural rules, naming conventions, and best practices, see:
- PROJECT_STANDARDS.md - Complete project standards and guidelines
- CLAUDE.md - Project identity and workflow guidelines
Resources
References/
PROJECT_STANDARDS.md- Detailed coding standards and architecture rulesCLAUDE.md- Project identity, workflow, and command reference
These references provide in-depth guidance for specific aspects of the architecture and should be consulted when implementing complex features or clarifying architectural decisions.
Usage Notes:
- This skill applies to any Rust backend project using Hexagonal Architecture
- When creating new domains, copy structure from existing domains (e.g.,
user/) - Always verify architecture compliance with
cargo check - API changes must be documented in
docs/api.md