CtrlK
BlogDocsLog inGet started
Tessl Logo

axum-project-starter

Scaffold a production-ready Axum 0.8+ API with Rust 2024 edition, Tower middleware, SQLx database integration, structured error handling, tracing, and shared state patterns.

84

Quality

80%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Optimize this skill with Tessl

npx tessl skill review --optimize ./backend-rust/axum-project-starter/SKILL.md
SKILL.md
Quality
Evals
Security

Axum Project Starter

Scaffold a production-ready Axum 0.8+ API with Rust 2024 edition, Tower middleware, SQLx database integration, structured error handling, tracing, and shared state patterns.

Prerequisites

  • Rust >= 1.85 (2024 edition support)
  • Cargo
  • PostgreSQL (or SQLite for development)
  • sqlx-cli (cargo install sqlx-cli --features postgres)

Scaffold Command

cargo init <project-name>
cd <project-name>

# Core dependencies
cargo add axum@0.8 --features macros
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
cargo add sqlx --features runtime-tokio,tls-rustls,postgres,uuid,chrono,migrate
cargo add uuid --features v4,serde
cargo add chrono --features serde
cargo add tower --features timeout,limit
cargo add tower-http --features cors,trace,compression-gzip
cargo add tracing tracing-subscriber
cargo add thiserror
cargo add dotenvy

# Dev dependencies
cargo add --dev reqwest --features json
cargo add --dev tokio-test

Project Structure

src/
  main.rs                  # Server bootstrap, router assembly
  config.rs                # Typed configuration from env
  db.rs                    # SQLx pool initialization
  errors.rs                # AppError implementing IntoResponse
  state.rs                 # AppState struct (pool, config, etc.)
  routes/
    mod.rs                 # Router composition
    health.rs              # Health check
    users.rs               # User CRUD handlers
  models/
    mod.rs
    user.rs                # User struct, request/response types
  middleware/
    mod.rs
    auth.rs                # Auth layer via Tower middleware
  extractors/
    mod.rs
    auth.rs                # Custom extractor for authenticated user
migrations/
  YYYYMMDD_initial.sql
Cargo.toml
.env
.env.example                 # Template for required env vars (commit this)

Key Conventions

  • Shared state via State(AppState) extractor — AppState wraps an Arc internally or is cloned cheaply
  • Handlers are async functions returning Result<impl IntoResponse, AppError>
  • Use Router::new().route("/path", get(handler)) — one router per module, merged in routes/mod.rs
  • Extractors order matters: State and Path before Json (body can only be consumed once, must be last)
  • All middleware is Tower-based: ServiceBuilder, Layer, and Service traits
  • IntoResponse on error types for clean error propagation — no .unwrap() in handlers
  • Rust 2024 edition in Cargo.toml: edition = "2024"
  • Prefer #[axum::debug_handler] on handlers during development for better compile error messages

Essential Patterns

Application Bootstrap — main.rs

use std::net::SocketAddr;
use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

mod config;
mod db;
mod errors;
mod extractors;
mod middleware;
mod models;
mod routes;
mod state;

use state::AppState;

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();

    tracing_subscriber::registry()
        .with(tracing_subscriber::fmt::layer())
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "info,tower_http=debug".into()),
        )
        .init();

    let state = AppState::new().await;

    sqlx::migrate!("./migrations")
        .run(&state.pool)
        .await
        .expect("Failed to run migrations");

    let app = routes::create_router(state);

    let port: u16 = std::env::var("PORT")
        .unwrap_or_else(|_| "8080".into())
        .parse()
        .expect("PORT must be a number");

    let addr = SocketAddr::from(([0, 0, 0, 0], port));
    let listener = TcpListener::bind(addr).await.unwrap();
    tracing::info!("Listening on {addr}");

    axum::serve(listener, app).await.unwrap();
}

Shared State — state.rs

use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;

#[derive(Clone)]
pub struct AppState {
    pub pool: PgPool,
}

impl AppState {
    pub async fn new() -> Self {
        let database_url =
            std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");

        let pool = PgPoolOptions::new()
            .max_connections(5)
            .connect(&database_url)
            .await
            .expect("Failed to connect to database");

        Self { pool }
    }
}

Router Composition — routes/mod.rs

use axum::Router;
use tower_http::{
    cors::CorsLayer,
    trace::TraceLayer,
    compression::CompressionLayer,
};

use crate::state::AppState;

mod health;
mod users;

pub fn create_router(state: AppState) -> Router {
    Router::new()
        .merge(health::router())
        .nest("/api/users", users::router())
        .layer(CompressionLayer::new())
        .layer(TraceLayer::new_for_http())
        .layer(CorsLayer::permissive())
        .with_state(state)
}

Handlers — routes/users.rs

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use uuid::Uuid;

use crate::errors::AppError;
use crate::models::user::{CreateUserRequest, User, UserQuery};
use crate::state::AppState;

pub fn router() -> Router<AppState> {
    Router::new()
        .route("/", post(create_user).get(list_users))
        .route("/{id}", get(get_user).put(update_user).delete(delete_user))
}

// IMPORTANT: Extractors must be ordered: State first, then Path/Query, then Json last.
// Json consumes the request body — placing it before Path causes "body already consumed" errors.

#[axum::debug_handler]
async fn create_user(
    State(state): State<AppState>,
    Json(body): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<User>), AppError> {
    let user = sqlx::query_as!(
        User,
        r#"INSERT INTO users (id, email, name)
           VALUES ($1, $2, $3)
           RETURNING id, email, name, created_at, updated_at"#,
        Uuid::new_v4(),
        body.email,
        body.name,
    )
    .fetch_one(&state.pool)
    .await?;

    Ok((StatusCode::CREATED, Json(user)))
}

async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<User>, AppError> {
    let user = sqlx::query_as!(
        User,
        "SELECT id, email, name, created_at, updated_at FROM users WHERE id = $1",
        id,
    )
    .fetch_optional(&state.pool)
    .await?
    .ok_or(AppError::NotFound(format!("User {id} not found")))?;

    Ok(Json(user))
}

async fn list_users(
    State(state): State<AppState>,
    Query(params): Query<UserQuery>,
) -> Result<Json<Vec<User>>, AppError> {
    let limit = params.limit.unwrap_or(20).min(100);
    let offset = params.offset.unwrap_or(0);

    let users = sqlx::query_as!(
        User,
        "SELECT id, email, name, created_at, updated_at FROM users LIMIT $1 OFFSET $2",
        limit as i64,
        offset as i64,
    )
    .fetch_all(&state.pool)
    .await?;

    Ok(Json(users))
}

async fn update_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
    Json(body): Json<CreateUserRequest>,
) -> Result<Json<User>, AppError> {
    let user = sqlx::query_as!(
        User,
        r#"UPDATE users SET email = $1, name = $2, updated_at = NOW()
           WHERE id = $3
           RETURNING id, email, name, created_at, updated_at"#,
        body.email,
        body.name,
        id,
    )
    .fetch_optional(&state.pool)
    .await?
    .ok_or(AppError::NotFound(format!("User {id} not found")))?;

    Ok(Json(user))
}

async fn delete_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
    let rows = sqlx::query!("DELETE FROM users WHERE id = $1", id)
        .execute(&state.pool)
        .await?
        .rows_affected();

    if rows == 0 {
        return Err(AppError::NotFound(format!("User {id} not found")));
    }

    Ok(StatusCode::NO_CONTENT)
}

Model — models/user.rs

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;

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

#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
    pub email: String,
    pub name: String,
}

#[derive(Debug, Deserialize)]
pub struct UserQuery {
    pub limit: Option<u32>,
    pub offset: Option<u32>,
}

Error Handling — errors.rs

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("Not found: {0}")]
    NotFound(String),

    #[error("Bad request: {0}")]
    BadRequest(String),

    #[error("Unauthorized")]
    Unauthorized,

    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("Internal error: {0}")]
    Internal(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            AppError::Unauthorized => {
                (StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
            }
            AppError::Database(e) => {
                tracing::error!("Database error: {e}");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Internal server error".to_string(),
                )
            }
            AppError::Internal(msg) => {
                tracing::error!("Internal error: {msg}");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Internal server error".to_string(),
                )
            }
        };

        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}

Custom Extractor — extractors/auth.rs

use axum::{
    async_trait,
    extract::FromRequestParts,
    http::request::Parts,
};
use uuid::Uuid;

use crate::errors::AppError;
use crate::state::AppState;

pub struct AuthenticatedUser {
    pub user_id: Uuid,
}

#[async_trait]
impl FromRequestParts<AppState> for AuthenticatedUser {
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        let auth_header = parts
            .headers
            .get("Authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or(AppError::Unauthorized)?;

        let token = auth_header
            .strip_prefix("Bearer ")
            .ok_or(AppError::Unauthorized)?;

        // Validate token and extract user_id here
        let user_id = validate_token(token)?;

        Ok(AuthenticatedUser { user_id })
    }
}

fn validate_token(token: &str) -> Result<Uuid, AppError> {
    // Replace with actual JWT validation using `jsonwebtoken` crate
    let _ = token;
    Err(AppError::Unauthorized)
}

Tower Middleware Layer — middleware/auth.rs

use axum::{
    extract::Request,
    http::StatusCode,
    middleware::Next,
    response::Response,
};

/// Use with `axum::middleware::from_fn`
pub async fn require_auth(
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let auth_header = request
        .headers()
        .get("Authorization")
        .and_then(|v| v.to_str().ok());

    match auth_header {
        Some(header) if header.starts_with("Bearer ") => {
            // Validate token here
            Ok(next.run(request).await)
        }
        _ => Err(StatusCode::UNAUTHORIZED),
    }
}

// Apply to specific routes:
// Router::new()
//     .route("/protected", get(handler))
//     .layer(axum::middleware::from_fn(require_auth))

First Steps After Scaffold

  1. Copy .env.example to .env and fill in DATABASE_URL and PORT
  2. Run cargo build to fetch and compile all dependencies
  3. Ensure PostgreSQL is running and create the database: createdb myapp_dev
  4. Run migrations: sqlx migrate run
  5. Start the dev server: cargo watch -x run (install cargo-watch first if needed)
  6. Verify the health endpoint: curl http://localhost:8080/api/health

Common Commands

# Development with auto-reload
cargo install cargo-watch
cargo watch -x run

# Run
cargo run

# Build release
cargo build --release

# Run tests
cargo test

# Lint
cargo clippy -- -D warnings

# Format
cargo fmt

# Create migration
sqlx migrate add <name>

# Run migrations
sqlx migrate run

# Prepare offline query data (for CI without DB)
cargo sqlx prepare

# Check (faster than build — type checking only)
cargo check

Integration Notes

  • Database: SQLx with compile-time query checking. Use cargo sqlx prepare to generate .sqlx/ query cache for CI builds without a live database.
  • Auth: Use jsonwebtoken crate for JWT. Implement as a custom extractor (FromRequestParts) or as a from_fn middleware layer. Extractors are more ergonomic when you need the user in the handler.
  • Middleware Stack: Tower ServiceBuilder composes layers. Order matters — layers wrap from bottom to top. Place TraceLayer outermost, auth layers on specific route groups.
  • WebSockets: Axum has built-in WebSocket support via axum::extract::ws::WebSocket. Upgrade with WebSocketUpgrade extractor.
  • Testing: Use axum::body::Body and tower::ServiceExt (oneshot) to test handlers without starting a server. Build the router with a test database pool.
  • Graceful Shutdown: Use tokio::signal with axum::serve(...).with_graceful_shutdown(signal).
  • Docker: Multi-stage build — rust:1.85-slim for build, debian:bookworm-slim for runtime with only the compiled binary.
  • Streaming: Return axum::body::Body::from_stream() for streaming responses (SSE, large file downloads).
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.