| name | rust |
| description | Use when building Axum applications, implementing type-safe handlers, working with SQLx, setting up error handling with thiserror, or writing Rust backend services. |
| disable-model-invocation | true |
Rust Backend Patterns
Overview
Rust patterns for building backend services with Axum.
Project Structure
project/
โโโ src/
โ โโโ main.rs # Entry point
โ โโโ lib.rs # Library root
โ โโโ config.rs # Configuration
โ โโโ error.rs # Error types
โ โโโ routes/ # Route handlers
โ โ โโโ mod.rs
โ โ โโโ users.rs
โ โโโ services/ # Business logic
โ โโโ repositories/ # Data access
โ โโโ models/ # Domain models
โ โโโ middleware/ # HTTP middleware
โโโ migrations/ # SQLx migrations
โโโ tests/ # Integration tests
โโโ Cargo.toml
โโโ .env
Axum Application
Main Application
use axum::{
routing::{get, post},
Router,
};
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
mod config;
mod error;
mod routes;
mod services;
mod repositories;
use config::Config;
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool,
pub config: Arc<Config>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
tracing_subscriber::init();
let config = Config::from_env()?;
let pool = PgPoolOptions::new()
.max_connections(config.database.max_connections)
.connect(&config.database.url)
.await?;
sqlx::migrate!().run(&pool).await?;
let state = AppState {
db: pool,
config: Arc::new(config),
};
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.nest("/api/users", routes::users::router())
.with_state(state)
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
tracing::info!("listening on {}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
}
Configuration
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config {
pub database: DatabaseConfig,
pub jwt: JwtConfig,
}
#[derive(Debug, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
}
#[derive(Debug, Deserialize)]
pub struct JwtConfig {
pub secret: String,
#[serde(default = "default_expiry")]
pub expiry_hours: u64,
}
fn default_max_connections() -> u32 { 10 }
fn default_expiry() -> u64 { 24 }
impl Config {
pub fn from_env() -> Result<Self, config::ConfigError> {
config::Config::builder()
.add_source(config::Environment::default().separator())
.()?
.()
}
}
Error Handling
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Unauthorized")]
Unauthorized,
#[error("Forbidden")]
Forbidden,
#[error("Database error")]
Database(#[from] sqlx::Error),
#[error("Internal error")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()),
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR", msg.clone()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, , .()),
AppError::Forbidden => (StatusCode::FORBIDDEN, , .()),
AppError::(e) => {
tracing::error!(, e);
(StatusCode::INTERNAL_SERVER_ERROR, , .())
}
AppError::(e) => {
tracing::error!(, e);
(StatusCode::INTERNAL_SERVER_ERROR, , .())
}
};
(
status,
(json!({
: {
: code,
: message
}
})),
).()
}
}
<T> = std::result::<T, AppError>;
Models and DTOs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use validator::Validate;
#[derive(Debug, FromRow, Serialize)]
pub struct User {
pub id: Uuid,
pub name: String,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize, Validate)]
pub struct CreateUser {
#[validate(length(min = 2, max = 100))]
pub name: String,
#[validate(email)]
pub email: String,
#[validate(length(min = 8))]
pub password: String,
}
#[derive(Debug, Deserialize, Validate)]
pub struct UpdateUser {
#[validate(length(min = 2, max = 100))]
pub name: Option<String>,
#[validate(email)]
pub email: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct {
id: Uuid,
name: ,
email: ,
created_at: DateTime<Utc>,
}
<User> {
(user: User) {
{
id: user.id,
name: user.name,
email: user.email,
created_at: user.created_at,
}
}
}
Repository Pattern
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use crate::models::user::{User, CreateUser, UpdateUser};
pub struct UserRepository<'a> {
pool: &'a PgPool,
}
impl<'a> UserRepository<'a> {
pub fn new(pool: &'a PgPool) -> Self {
Self { pool }
}
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<User>> {
sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(self.pool)
.await
.map_err(AppError::Database)
}
pub async fn find_by_email(&self, email: &str) -> Result<Option<User>> {
sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", email)
.fetch_optional(self.pool)
.await
.map_err(AppError::Database)
}
(&, limit: , offset: ) <<User>> {
sqlx::query_as!(
User,
,
limit,
offset
)
.(.pool)
.
.(AppError::Database)
}
(&, input: &CreateUser, password_hash: &) <User> {
sqlx::query_as!(
User,
,
input.name,
input.email,
password_hash
)
.(.pool)
.
.(AppError::Database)
}
(&, id: Uuid, input: &UpdateUser) <<User>> {
sqlx::query_as!(
User,
,
id,
input.name,
input.email
)
.(.pool)
.
.(AppError::Database)
}
(&, id: Uuid) <> {
= sqlx::query!(, id)
.(.pool)
.
.(AppError::Database)?;
(result.() > )
}
}
Route Handlers
use axum::{
extract::{Path, Query, State},
routing::{get, post, delete},
Json, Router,
};
use uuid::Uuid;
use validator::Validate;
use crate::{
error::{AppError, Result},
models::user::{CreateUser, UpdateUser, UserResponse},
repositories::user::UserRepository,
services::user::UserService,
AppState,
};
#[derive(Debug, serde::Deserialize)]
pub struct ListQuery {
#[serde(default = "default_page")]
page: i64,
#[serde(default = "default_limit")]
limit: i64,
}
fn default_page() -> i64 { 1 }
fn default_limit() -> i64 { 20 }
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_users).post(create_user))
.route("/:id", get(get_user).patch(update_user).delete(delete_user))
}
async fn list_users(
State(state): State<AppState>,
Query(query): Query<ListQuery>,
) <Json<<UserResponse>>> {
= UserRepository::(&state.db);
= (query.page - ) * query.limit;
= repo.(query.limit, offset).?;
((users.().(::into).()))
}
(
(state): State<AppState>,
(id): Path<Uuid>,
) <Json<UserResponse>> {
= UserRepository::(&state.db);
= repo
.(id)
.?
.(|| AppError::(.()))?;
((user.()))
}
(
(state): State<AppState>,
(input): Json<CreateUser>,
) <Json<UserResponse>> {
input.().(|e| AppError::(e.()))?;
= UserService::(&state.db);
= service.(input).?;
((user.()))
}
(
(state): State<AppState>,
(id): Path<Uuid>,
(input): Json<UpdateUser>,
) <Json<UserResponse>> {
input.().(|e| AppError::(e.()))?;
= UserRepository::(&state.db);
= repo
.(id, &input)
.?
.(|| AppError::(.()))?;
((user.()))
}
(
(state): State<AppState>,
(id): Path<Uuid>,
) <()> {
= UserRepository::(&state.db);
!repo.(id).? {
(AppError::(.()));
}
(())
}
Testing
use axum::{
body::Body,
http::{Request, StatusCode},
};
use tower::ServiceExt;
use serde_json::json;
#[tokio::test]
async fn test_list_users() {
let app = create_test_app().await;
let response = app
.oneshot(
Request::builder()
.uri("/api/users")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_create_user() {
let app = create_test_app().await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/users")
.header("content-type", "application/json")
.(Body::(
serde_json::(&json!({
: ,
: ,
:
}))
.(),
))
.(),
)
.
.();
(response.(), StatusCode::OK);
}
Rust Axum patterns for backend development