My Rust Backend Journey: A Practical Guide from 0 to 1
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.
Layer | Responsibility Description | Core Features |
---|---|---|
router | Route definition, permission binding, HTTP processing, intuitive functional entry | Router + Handler merged |
service | Aggregate business logic, focus on behavior, encapsulate processing flow | Pure business logic, no framework dependency |
repo | Database access, encapsulate SQL queries | First-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.
Module | Description | Examples |
---|---|---|
entity | Maps database structure, used for SQLx queries | UserWithRolesEntity |
dto | Receives frontend input parameters, field validation | CreateUserDto , UserQueryDto |
vo | Returns fields needed for frontend display, secure desensitization | UserListVo , 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-inJson<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
Type | Example Number | Description |
---|---|---|
Table Structure | 100100 ~ 100500 | Users, roles, menus, etc. basic tables |
Foreign Keys / Triggers | 100800 , 100900 | Table constraints, log archiving |
Views | 1070xx | Aggregate views, such as user permissions |
Functions | 1080xx | Query encapsulation, login, permission calculation |
Data Seeds | 1090xx | Initialize 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: More comprehensive documentation, introducing related design 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”!
🔗 Project Links and More Content
- 📦 Project Source Code:https://github.com/idaibin/rustzen-admin