oryjk

rust-hexagonal-architecture

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.

oryjk 0 Updated 4mo ago
GitHub

Install

npx skillscat add oryjk/rust-hexagonal-architecture

Install via the SkillsCat registry.

SKILL.md

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:

  1. Analyze the domain - identify business entities and their relationships
  2. Define the structure - create domain folders with vertical slicing
  3. Implement from core to edges: Domain → Ports → Application → Adapters
  4. Verify with cargo check after every file edit
  5. 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 scripts

Layer 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 thiserror for 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 + Sync bounds
  • ❌ 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, DTOs
  • persistence/ - Database repositories, SQL queries
  • api/ - 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)
  ↓
Database

Handler 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., asset not assets)

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.rs or verb_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::Error to 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 (use chrono::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

  1. Analyze domain → define ports → implement logic → create adapters
  2. Run cargo check after every file edit
  3. Update docs/api.md if API changes
  4. 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.md

Anti-Patterns to Avoid

Never do these:

  • Handler calling Repository directly
  • Domain struct with #[derive(sqlx::FromRow)]
  • Using infrastructure folder name (use adapters)
  • 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:

Resources

References/

  • PROJECT_STANDARDS.md - Detailed coding standards and architecture rules
  • CLAUDE.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