rust-hexagonal-architecture

📁 oryjk/rust-hexagonal-architecture 📅 12 days ago
3
总安装量
2
周安装量
#60349
全站排名
安装命令
npx skills add https://github.com/oryjk/rust-hexagonal-architecture --skill rust-hexagonal-architecture

Agent 安装分布

opencode 1
codex 1
claude-code 1

Skill 文档

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