Files
zesdex/apps/infrastructure/skills/kana-rust-backend-best-practice/SKILL.md
T
asepharyana 8ecc588a3e feat(best_practice): add code quality scanning and commit message validation
- Implemented a code quality scanner that checks for common clean-code violations in Rust source files, including missing documentation, usage of `.unwrap()` in production code, and commented-out code.
- Introduced a commit message validator that follows the Conventional Commits specification, ensuring proper formatting and providing suggestions for invalid messages.
- Created a unified BestPracticeEngine to encapsulate the functionalities of skills, architecture audits, code quality checks, and commit message validation.
- Added tests for both the code quality scanner and commit message validator to ensure reliability and correctness.
2026-07-21 07:21:22 +07:00

15 KiB

name, description
name description
kana-rust-backend-best-practice Reference guide for building a Rust clean-architecture backend with Axum, SeaORM, Argon2, JWT, and sea-orm-migration. Use when scaffolding a new Rust service, adding a feature (domain + use-case + repository + handler), or reviewing Rust code against the axum-clean-architecture reference layout.

Axum Clean Architecture Skill

Reference stack (see ../axum-clean-architecture):

Layer Tech
HTTP framework Axum 0.8
ORM SeaORM 1.1 (PostgreSQL via sqlx + rustls)
Migrations sea-orm-migration
Auth Argon2 (password hashing) + jsonwebtoken (JWT)
Validation zod-rs (schema-driven, mirrors Zod)
Pagination paginator-rs + paginator-sea-orm + paginator-axum
Observability tracing + tracing-subscriber
Middleware tower-http (CORS, TraceLayer)
Runtime Tokio (full features)
Error handling anyhow (app-level), typed domain errors

0. Workspace layout

axum-clean-architecture/
├── Cargo.toml               # workspace, resolver = "3"
├── apps/
│   ├── iam/                 # core domain library (lib crate)
│   │   └── src/
│   │       ├── domain/      # entities, repository traits, domain errors
│   │       ├── application/ # use cases + port traits
│   │       ├── infrastructure/ # SeaORM repos + auth services
│   │       └── presentation/   # Axum handlers, DTOs, middleware, state
│   ├── gateway/             # binary — assembles router, runs server
│   └── bootstrap/           # binary — seeds permissions/roles/admin
├── .config/                 # AppServer, database/env helpers
└── .migrations/             # sea-orm-migration crate

The iam app is a library crate. gateway and bootstrap depend on it.


1. Dependency rules (strictly enforced)

presentation → application → domain
infrastructure → domain        (implements domain traits)
presentation → infrastructure  (only to wire AppState)
  • Domain has zero external crate dependencies beyond uuid, chrono.
  • Use cases depend only on port traits — never on concrete infrastructure types.
  • Presentation instantiates use cases from AppState on every request; use cases are not stored.

2. Domain layer

Entity pattern

Plain Rust structs — no derives beyond what domain logic needs. No ORM annotations.

// domain/user/entity.rs
pub struct User {
    pub id: Uuid,
    pub email: String,
    pub password_hash: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

pub struct NewUser {
    pub id: Uuid,
    pub email: String,
    pub password_hash: String,
}

#[derive(Default)]
pub struct UserPatch {
    pub email: Option<String>,
    pub password_hash: Option<String>,
}

Repository trait pattern

Use impl Future in trait methods (Rust 2024 edition, no async_trait needed). Always Send + Sync on the trait.

// domain/user/repository.rs
pub trait UserRepository: Send + Sync {
    fn find_by_id(&self, id: Uuid)
        -> impl Future<Output = Result<Option<User>, RepositoryError>> + Send;
    fn find_by_email(&self, email: &str)
        -> impl Future<Output = Result<Option<User>, RepositoryError>> + Send;
    fn create(&self, user: NewUser)
        -> impl Future<Output = Result<User, RepositoryError>> + Send;
    fn update(&self, id: Uuid, patch: UserPatch)
        -> impl Future<Output = Result<User, RepositoryError>> + Send;
    fn delete(&self, id: Uuid)
        -> impl Future<Output = Result<(), RepositoryError>> + Send;
    fn list(&self, params: &PaginationParams)
        -> impl Future<Output = Result<PaginatorResponse<User>, RepositoryError>> + Send;
}

Shared RepositoryError (lives in domain)

pub enum RepositoryError {
    NotFound,
    Conflict(String),
    Database(String),
}

Domain errors

Per-aggregate. AuthError lives in domain/auth/errors.rs:

pub enum AuthError {
    InvalidCredentials,
    EmailAlreadyExists,
    UserNotFound,
    PasswordHashFailed(String),
    PasswordVerificationFailed(String),
    TokenGenerationFailed(String),
    InvalidToken(String),
    RepositoryError(String),
}

3. Application layer

Port traits (interfaces for external services)

// application/auth/ports/password.rs
pub trait PasswordService: Send + Sync {
    fn hash(&self, password: &str)
        -> impl Future<Output = Result<String, PasswordError>> + Send;
    fn verify(&self, password: &str, hash: &str)
        -> impl Future<Output = Result<bool, PasswordError>> + Send;
}
// application/auth/ports/token.rs
pub trait TokenService: Send + Sync {
    fn generate_auth_tokens(&self, sub: &str)
        -> impl Future<Output = Result<(String, String), TokenError>> + Send;
    fn verify_access_token(&self, token: &str)
        -> Result<String, TokenError>;
}

Use case pattern

Generic over port traits and repository traits. Constructed in the handler, not stored.

// application/user/use_cases/create.rs
pub struct CreateUserCommand { pub email: String, pub password: String }

pub struct CreateUserUseCase<P, R> {
    password_service: P,
    user_repository: R,
}

impl<P: PasswordService, R: UserRepository> CreateUserUseCase<P, R> {
    pub fn new(password_service: P, user_repository: R) -> Self { ... }

    pub async fn execute(&self, cmd: CreateUserCommand) -> Result<User, AuthError> {
        // 1. guard: check uniqueness
        // 2. hash password via port
        // 3. create domain entity with Uuid::new_v4()
        // 4. persist via repository
        // 5. log + return
        info!(user_id = %user.id, "user created");
        Ok(user)
    }
}

Use case naming convention

File Struct Command/Query
create.rs CreateXxxUseCase CreateXxxCommand
update.rs UpdateXxxUseCase UpdateXxxCommand
delete.rs DeleteXxxUseCase DeleteXxxCommand
detail.rs XxxDetailUseCase XxxDetailQuery
list.rs ListXxxsUseCase takes &PaginationParams

4. Infrastructure layer

SeaORM repository implementation

// infrastructure/repository/user.rs
#[derive(Clone)]
pub struct SeaOrmUserRepository { db: DatabaseConnection }

// Convert ORM Model → domain entity here (not in domain)
impl From<Model> for User { ... }

// Map DbErr → RepositoryError
fn map_db_err(e: DbErr) -> RepositoryError {
    match e {
        DbErr::RecordNotFound(_) => RepositoryError::NotFound,
        other => { error!(error = %other, "database operation failed"); RepositoryError::Database(other.to_string()) }
    }
}

impl UserRepository for SeaOrmUserRepository {
    async fn create(&self, user: NewUser) -> Result<User, RepositoryError> {
        let model = ActiveModel {
            id: Set(user.id),
            email: Set(user.email),
            password_hash: Set(user.password_hash),
            created_at: Set(now),
            updated_at: Set(now),
        };
        let inserted = model.insert(&self.db).await.map_err(|e| match e {
            DbErr::Exec(ref msg) | DbErr::Query(ref msg)
                if msg.to_string().contains("unique") =>
                RepositoryError::Conflict("email already exists".into()),
            other => RepositoryError::Database(other.to_string()),
        })?;
        Ok(User::from(inserted))
    }
}

Pagination uses paginator-sea-orm:

let response = UserEntity::find()
    .paginate_with(&self.db, params)
    .await
    .map_err(|e| RepositoryError::Database(e.to_string()))?;
let mapped: Vec<User> = response.data.into_iter().map(User::from).collect();
Ok(PaginatorResponse { data: mapped, meta: response.meta })

Auth services

  • Argon2PasswordService: uses spawn_blocking for CPU-bound hashing, SaltString::generate(OsRng).
  • JwtTokenService: stores secret: Vec<u8>, generates separate access/refresh tokens with a type claim. verify_access_token checks claims.token_type == "access".

SeaORM entities (ORM models)

Live in infrastructure/repository/entities/. One file per table. Junction tables (user_role, role_permission) have composite primary keys. Timestamps use DateTimeWithTimeZone.


5. Presentation layer

AppState

Concrete types only — no trait objects. Cheap to clone because DatabaseConnection is internally Arc-backed.

#[derive(Clone)]
pub struct AppState {
    pub password_service: Argon2PasswordService,
    pub token_service: JwtTokenService,
    pub user_repository: SeaOrmUserRepository,
    pub role_repository: SeaOrmRoleRepository,
    pub permission_repository: SeaOrmPermissionRepository,
}

Injected via Extension(state) on every handler. Use cases are constructed inside handlers.

AppError

pub enum AppError { BadRequest(String), Unauthorized, Forbidden, NotFound, Conflict(String), Internal(String) }

impl IntoResponse for AppError { /* maps to HTTP status + JSON { "error": "..." } */ }

impl From<AuthError> for AppError { ... }
impl From<RepositoryError> for AppError { ... }
impl From<TokenError> for AppError { ... }

Internal errors are logged with tracing::error! before returning a generic 500 message.

Handler pattern

#[instrument(skip_all, fields(actor = %actor.id, email = %req.email))]
pub async fn create(
    Extension(state): Extension<AppState>,
    Extension(actor): Extension<AuthenticatedUser>,
    Json(req): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
    let use_case = CreateUserUseCase::new(
        state.password_service.clone(),
        state.user_repository.clone(),
    );
    let user = use_case.execute(req.into()).await?;
    Ok((StatusCode::CREATED, Json(user.into())))
}

Rules:

  • Always #[instrument(skip_all, fields(...))] on every handler.
  • Use ? to propagate AppError (via From impls).
  • 201 CREATED for POST, 204 NO_CONTENT for DELETE, 200 OK for everything else.
  • Pagination handlers return PaginatedJson<Dto> via paginator-axum.

DTO pattern

#[derive(Debug, Serialize, Deserialize, ZodSchema)]
pub struct CreateUserRequest {
    #[zod(email)]
    pub email: String,
    #[zod(min_length(8), max_length(128))]
    pub password: String,
}

impl From<CreateUserRequest> for CreateUserCommand { ... }

#[derive(Debug, Serialize)]
pub struct UserResponse { pub id: Uuid, pub email: String, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc> }

impl From<User> for UserResponse { ... }
  • Request structs: Deserialize + ZodSchema. Use #[zod(...)] for field-level validation.
  • Response structs: Serialize only. Never expose password_hash.
  • Conversions: impl From<Request> for Command and impl From<DomainEntity> for Response.

Middleware

Auth middleware (presentation/middleware/auth.rs):

  • Extracts Bearer <token> from Authorization header.
  • Calls state.token_service.verify_access_token(token).
  • Inserts AuthenticatedUser { id: Uuid } into request extensions.

Permission check (presentation/middleware/permission.rs):

  • Called inline from handlers: ensure_permission(&state, &actor, "users:write").await?.
  • Queries permission_repository.find_for_user(actor.id) and checks by name.

Router assembly

pub fn build_router(state: AppState) -> Router {
    Router::new()
        .nest("/auth", auth::router())
        .nest("/me", me::router())
        .nest("/users", user::router())
        .nest("/roles", role::router())
        .nest("/permissions", permission::router())
        .layer(Extension(state))
}

Gateway nests the IAM router at /api/v1/iam and adds a health check at /.


6. Migrations (sea-orm-migration)

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager.create_table(
            Table::create()
                .table(Users::Table)
                .if_not_exists()
                .col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
                .col(ColumnDef::new(Users::Email).string().not_null().unique_key())
                ...
                .to_owned(),
        ).await
    }
    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager.drop_table(Table::drop().table(Users::Table).to_owned()).await
    }
}

#[derive(DeriveIden)]
pub enum Users { Table, Id, Email, PasswordHash, CreatedAt, UpdatedAt }

Naming convention: m{YYYYMMDD}_{6-digit-seq}_{description}.rs, e.g. m20260413_000001_create_users.rs.


7. Bootstrap pattern

A separate bootstrap binary seeds idempotent system data (permissions, roles, admin user):

// Check existence before inserting — fully idempotent
if permission_repo.find_by_name("rbac:manage").await?.is_none() {
    permission_repo.create(...).await?;
}

Standard permissions:

  • rbac:manage, users:read, users:write, roles:read, roles:write, permissions:read, permissions:write

8. Adding a new aggregate (checklist)

  1. Domain: domain/{name}/entity.rs (entity + NewXxx + XxxPatch), domain/{name}/repository.rs (trait + errors), domain/{name}/errors.rs if needed.
  2. Application: application/{name}/mod.rs, application/{name}/use_cases/{create,update,delete,detail,list}.rs.
  3. Infrastructure entity: infrastructure/repository/entities/{name}.rs (SeaORM model).
  4. Infrastructure repo: infrastructure/repository/{name}.rs (From<Model>, impl XxxRepository for SeaOrmXxxRepository).
  5. Add to AppState: {name}_repository: SeaOrmXxxRepository.
  6. Presentation DTO: presentation/{name}/dto.rs (Request + Response with From impls).
  7. Presentation handlers: presentation/{name}/handlers.rs (#[instrument], construct use case, return DTO).
  8. Presentation router: presentation/{name}/mod.rs (define routes with axum_route_macro or Router::new().route(...)).
  9. Nest in build_router.
  10. Migration: new file in .migrations/src/ following naming convention.
  11. Bootstrap: seed any required initial data.

9. Key conventions

  • Edition 2024 — use impl Future in traits, not #[async_trait].
  • All timestamps are DateTime<Utc> in domain; DateTimeWithTimeZone in SeaORM models; convert with .with_timezone(&Utc).
  • UUIDs generated with Uuid::new_v4() in the use case, not the repository.
  • Unique-constraint conflicts detected via string match on DbErr::Exec/DbErr::Query containing "unique" — map to RepositoryError::Conflict.
  • tracing::instrument on every handler; log user/actor IDs as structured fields.
  • warn! for expected failures (wrong password, permission denied), error! for unexpected DB errors.
  • Response structs never expose internal fields (password_hash, internal IDs from junction tables).