Back to blog

My Rust Backend Journey: A Practical Guide from 0 to 1

As a frontend developer transitioning into Rust backend development, this is an exploration of structure, clarity, and minimalism. From Tauri to Axum, from chaotic stacking to three-layer architecture, this article systematically documents the core concepts and practical path in building the rustzen project, and serves as a development note for my future self.

migrated and adapted from idaibin/blog Read Chinese version

📦 GitHub:https://github.com/rustzen/rustzen-admin

I. What is Rustzen? Why this naming?

Rustzen is the name I chose for this project, meaning “Rust + Zen”: Rust brings performance and safety, Zen represents minimalism and order.

This is not just a combination of technology stacks, but my way of thinking about development experience:

  • Use minimal structure to express clearest intent
  • Use simplest path to build sustainable systems

Minimalism is not “doing less”, but “just right”. Every evolution of Rustzen is a question about “appropriate design + clear boundaries”.

I started from Tauri + SQLite, gradually moved to Axum + SQLx, and continuously refined the current structure through evolution. The starting point of this structure is a response to the following problems:

  • Early handler layer structure was loose, unclear responsibilities, routes and logic stacked, leading to maintenance difficulties;
  • Lack of clear data transmission specifications, logic duplication, increased testing costs;
  • In collaboration, due to unclear code structure, generated content was redundant and difficult to combine and reuse.

The emergence of Rustzen is a response to these problems—not pursuing complex frameworks, but seeking “just right” in structure.


II. Why adopt a three-layer architecture?

Why design it this way?

Clear structure is the foundation of maintainability. Three-layer separation allows each layer to focus only on its own responsibilities, reducing cognitive load.

LayerResponsibility DescriptionCore Features
routerRoute definition, permission binding, HTTP processing, intuitive functional entryRouter + Handler merged
serviceAggregate business logic, focus on behavior, encapsulate processing flowPure business logic, no framework dependency
repoDatabase access, encapsulate SQL queriesFirst-line error handling

Directory Example:

features/
└── system/
    ├── mod.rs           // Module declaration
    ├── user/
    │   ├── mod.rs
    │   ├── router.rs    // Routes + handlers
    │   ├── service.rs   // Business logic
    │   ├── repo.rs      // Data access
    │   ├── entity.rs    // Database entities
    │   ├── dto.rs       // Request data transfer objects
    │   └── vo.rs        // Response view objects
    ├── role/
    └── menu/

III. Why split data models into entity/dto/vo?

Why design it this way?

entity/dto/vo separation avoids field redundancy and data leakage, facilitating evolution and collaboration.

ModuleDescriptionExamples
entityMaps database structure, used for SQLx queriesUserWithRolesEntity
dtoReceives frontend input parameters, field validationCreateUserDto, UserQueryDto
voReturns fields needed for frontend display, secure desensitizationUserListVo, UserDetailVo
  • Use impl From<Entity> for VO to implement data conversion
  • Clear module responsibilities, facilitating AI and multi-person collaboration understanding and calling

IV. Authentication & Authorization: JWT + Server-side Permission Cache

System Overview:

  • Users first authenticate by logging in with their username and password. Upon successful authentication, the server issues a JWT containing only user identity information (such as user ID and username), not permissions.
  • All subsequent requests include the JWT; the server verifies the user’s identity via the JWT.
  • User permission information is stored in a server-side local cache (permission cache) and is looked up and checked as needed.
  • The permission cache mechanism reduces database pressure and improves system response speed.

4.1 Middleware Architecture

Core Middleware Components

  • âś… JWT Authentication Middleware: Verifies the token and extracts user identity information
  • âś… Permission Verification Middleware: Looks up and checks permissions in the server-side cache using the user ID

Middleware Implementation Example

// JWT authentication middleware - only responsible for identity verification
pub async fn jwt_auth_middleware(
    mut request: Request,
    next: Next,
) -> Result<Response, AppError> {
    let token = extract_token_from_header(&request)?;
    // JWT contains only user identity information
    let user_claims = verify_jwt_claims(&token)?;
    request.extensions_mut().insert(CurrentUser { id: user_claims.user_id });
    Ok(next.run(request).await)
}

// Permission middleware - checks permissions in the server-side cache
pub async fn permission_middleware(
    request: Request,
    next: Next,
    permissions: PermissionsCheck,
) -> Result<Response, AppError> {
    let current_user = request.extensions().get::<CurrentUser>()
        .ok_or(ServiceError::InvalidToken)?;
    // Look up permissions in the server-side cache using user ID
    let has_permission = PermissionService::check_permissions(current_user.id, &permissions).await?;
    if !has_permission {
        return Err(ServiceError::PermissionDenied.into());
    }
    Ok(next.run(request).await)
}

Declarative Permission Usage Example

.route_with_permission(
    "/",
    get(get_user_list),
    PermissionsCheck::Any(vec!["system:*", "system:user:*", "system:user:list"]),
)

4.2 Permission Cache and Renewal Mechanism

Why design it this way?

Permission caching reduces database pressure, the renewal mechanism ensures an active user experience, and automatic expiration cleanup enhances security.

  • âś… Local memory cache: Reduces database queries
  • âś… Automatic cache expiration cleanup: 1-hour expiration, requires re-authorization
  • âś… Permission renewal mechanism: Each time the user info API is accessed, the user’s permission cache is refreshed
  • âś… User status changes: When a user is disabled or forcibly logged out, the cache is immediately cleared
  • ⚙️ Future extension: Plan to implement a refresh token mechanism for improved security

How it works:

  • When a user makes a request, the server first verifies the JWT and extracts basic user info (ID, username)
  • The server then looks up the user’s permissions in the server-side cache using the user ID
  • Each time the user info API is called, the permission cache for that user is automatically refreshed
  • The JWT token itself does not yet support refresh; this is planned for future implementation

Permission Cache Implementation Example

// Permission cache manager
pub struct PermissionCacheManager {
    cache: Arc<RwLock<HashMap<i64, UserPermissionCache>>>,
}

impl PermissionService {
    pub async fn check_permissions(
        user_id: i64,
        permissions_check: &PermissionsCheck,
    ) -> Result<bool, ServiceError> {
        // Check if cache exists
        if let Some(cache) = PERMISSION_CACHE.get(user_id) {
            // Check if cache is expired
            if cache.is_expired() {
                PERMISSION_CACHE.remove(user_id);
                return Err(ServiceError::InvalidToken);
            }
            // Check permissions
            let has_permission = permissions_check.check(&cache.permissions);
            return Ok(has_permission);
        }
        Err(ServiceError::InvalidToken)
    }
}

// Permission cache renewal - implemented in AuthService
impl AuthService {
    // Get user login information
    #[tracing::instrument(name = "get_login_info", skip(pool))]
    pub async fn get_login_info(
        pool: &PgPool,
        user_id: i64,
    ) -> Result<UserInfoResponse, ServiceError> {
        // ...omitted...
        // Refresh user permission cache
        Self::refresh_user_permissions_cache(user_id, user.is_super_admin, permissions).await?;
        Ok(user_info)
    }

    pub async fn refresh_user_permissions_cache(
        user_id: i64,
        is_super_admin: bool,
        permissions: Vec<String>,
    ) -> Result<(), ServiceError> {
        if is_super_admin {
            // Super admin permission cache
            PermissionService::cache_user_permissions(user_id, vec!["*".to_string()]);
            return Ok(());
        }
        // Cache user permissions
        PermissionService::cache_user_permissions(user_id, permissions.clone());
        Ok(())
    }
}

4.3 Permission Granularity and Declaration

  • Permission granularity: The current project’s permission granularity is sufficiently fine
  • Permission codes: Such as system:user:list, system:user:create, etc.
  • Wildcard support: system:* supports module-wide permissions

Note: The current project does not require more fine-grained permission control; the current granularity meets business requirements.


4.4 Performance Optimization Strategies

  • Connection pool management: Configurable connection limits and timeout settings
  • Prepared statements: Automatically handled through SQLx
  • Efficient pagination: Use offset/limit for pagination queries
  • Index optimization: Build indexes for commonly queried fields

V. Error Handling and Unified Response

Why design it this way?

Unified error types and response structures improve frontend-backend collaboration efficiency, facilitating AI automatic generation of API documentation and test cases.

  • ServiceError + AppResult unified error handling
  • ApiResponse unified response format
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
    #[error("User is disabled")]
    UserIsDisabled,
    #[error("Username already exists")]
    UsernameConflict,
    #[error("Database query failed")]
    DatabaseQueryFailed,
    #[error("{0} not found")]
    NotFound(String),
    #[error("Insufficient permissions")]
    PermissionDenied,
    #[error("Invalid or expired token")]
    InvalidToken,
    // ... more business error types
}

Error Conversion Mechanism

impl From<ServiceError> for AppError {
    fn from(err: ServiceError) -> Self {
        let (status, code, message) = match err {
            ServiceError::NotFound(resource) => (
                StatusCode::NOT_FOUND,
                10001,
                format!("{} not found", resource),
            ),
            ServiceError::UsernameConflict => (
                StatusCode::CONFLICT,
                10201,
                "Username already exists".to_string(),
            ),
            ServiceError::DatabaseQueryFailed => (
                StatusCode::INTERNAL_SERVER_ERROR,
                20001,
                "Service temporarily unavailable, please try again later".to_string(),
            ),
            ServiceError::InvalidToken => (
                StatusCode::UNAUTHORIZED,
                30000,
                "Invalid or expired token, please login again".to_string(),
            ),
            // ... complete error mapping
        };
        AppError((status, code, message))
    }
}
  • All processing logic uniformly returns AppResult<T>, AppResult has built-in Json<ApiResponse<T>>, simplifying return types
  • Interface layer directly .await? destructures errors, no redundant processing needed
  • Error response structure is unified:

VI. Response Format: Two Schemes Unified Processing

Scheme One: Simple Response Format

// Single object or non-paginated response
ApiResponse::success(data)

// Response JSON:
{
  "code": 0,
  "message": "Success",
  "data": {
    "id": 1,
    "username": "admin"
  }
}

Scheme Two: Standard Pagination Format

// Paginated list interface
ApiResponse::page(data, total)

// Response JSON:
{
  "code": 0,
  "message": "Success",
  "data": [...],
  "total": 100
}
// In router.rs
pub async fn get_user_list(...) -> AppResult<Vec<UserListVo>> {
    let (users, total) = UserService::get_user_list(&pool, query).await?;
    Ok(ApiResponse::page(users, total))  // Pagination format
}

pub async fn get_user_by_id(...) -> AppResult<UserDetailVo> {
    let user = UserService::get_user_by_id(&pool, id).await?;
    Ok(ApiResponse::success(user))  // Simple format
}

VII. Database Migration Strategy: Zen-Style Numbering System

TypeExample NumberDescription
Table Structure100100 ~ 100500Users, roles, menus, etc. basic tables
Foreign Keys / Triggers100800, 100900Table constraints, log archiving
Views1070xxAggregate views, such as user permissions
Functions1080xxQuery encapsulation, login, permission calculation
Data Seeds1090xxInitialize users, menus, dictionary data, etc.
  • âś… Modular, traceable, excellent AI maintainability
  • ⚠️ Many files, consider merging files when reaching milestones later
  • Each file contains a single entity (view, function, or seed), conforming to Zen style

VIII. Module Dependency Relationship Management

Clear Module Boundaries

// In system/mod.rs
pub fn system_routes() -> Router<PgPool> {
    Router::new()
        .nest("/users", user_routes())
        .nest("/menus", menu_routes())
        .nest("/roles", role_routes())
        .nest("/dicts", dict_routes())
        .nest("/logs", log_routes())
}

Facing complexity, we choose clarity; Facing redundancy, we choose necessity; Facing chaos, we choose Zen.

Rustzen is not a framework, but a way of thinking. I hope it’s written not just for the current me, but also for those of you who are also on the road in the future.

IX. AI-Assisted Programming: The More Zen the Structure, the Higher the Efficiency

  • Clear structure allows AI to automatically generate modules (router/dto/service)
  • Unified naming, clear logic, facilitating prompt generation and batch refactoring
  • Clear module responsibilities, reducing error and rewrite costs
  • Log content consistent with function names, facilitating AI understanding and debugging

X. Conclusion: Rust + Zen, Minimalism is Not Less, But Just Right

Rustzen is an architectural exploration from chaos to order.

Minimalism is not about “doing less”, but about simple design, just right; it provides a clear foundation for AI collaboration, teamwork, and future expansion.

Architecture Advantages Summary

  • âś… Simplicity: Three-layer architecture reduces cognitive load
  • âś… AI-Friendly: Clear separation of concerns, facilitating AI assistance
  • âś… Type Safety: Strong type system, compile-time guarantees
  • âś… High Performance: Caching reduces database load, permission renewal mechanism
  • âś… Maintainability: Single responsibility principle, clear error handling
  • âś… Security: Proper cache expiration mechanism, requires re-authorization when cache expires

Future Improvement Directions

  • 🔄 Token Mechanism: Implement refresh token support
  • 🔄 Complete Feature Modules: After initial release, complete feature modules based on feedback
  • 🔄 Monitoring Metrics: Add performance monitoring and metric collection
  • 🔄 Cache Optimization: Consider Redis distributed caching for production environment
  • 🔄 Documentation Improvement: Add design notes and usage instructions
  • 🔄 Log Optimization: Optimize log format

XI. Closing Message: A Growing Project, Welcome Resonance

Rustzen is the crystallization of my practice from frontend to backend exploration. It’s not an endpoint, but a path to order and clarity.

May every developer find their own balance and direction in the pursuit of “just right”.

Welcome feedback and co-creation, let Rustzen become more developers’ choice of “just right”!