rust-hexagonal-architecture
npx skills add https://github.com/oryjk/rust-hexagonal-architecture --skill rust-hexagonal-architecture
Agent 安装分布
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:
- 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 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
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)
â
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.,
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.md
Anti-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