Build a Production Rust API in 45 Minutes with Claude 4.5

Create a high-performance REST API using Axum and Tokio with AI-assisted development for routing, error handling, and database integration.

Problem: Building Rust APIs Takes Forever

You want Rust's speed and safety for your API, but the learning curve is steep. Setting up async runtime, error handling, and database connections takes hours of documentation reading.

You'll learn:

  • Build a production-ready Axum API from scratch
  • Use Claude 4.5 to accelerate Rust development
  • Handle errors, validation, and database ops properly

Time: 45 min | Level: Intermediate


Why This Approach Works

Rust's type system catches bugs at compile time, but requires careful design. Claude 4.5 understands Rust's ownership model and can generate idiomatic code that actually compiles.

What we're building:

  • REST API with CRUD operations
  • PostgreSQL integration with SQLx
  • Proper error handling and validation
  • Production-ready logging and metrics

Prerequisites:

  • Rust 1.75+ installed (rustc --version)
  • Docker for PostgreSQL
  • Basic understanding of REST APIs

Solution

Step 1: Project Setup with Claude's Help

Ask Claude to generate the project structure:

Prompt to Claude:

"Create a new Rust Axum web API project with SQLx for PostgreSQL. Include proper error handling with thiserror, environment config with dotenvy, and structured logging with tracing. Generate the Cargo.toml with latest stable versions."

# Create project
cargo new rust-api
cd rust-api

Claude will generate your Cargo.toml:

[package]
name = "rust-api"
version = "0.1.0"
edition = "2021"

[dependencies]
# Web framework
axum = "0.7.4"
tokio = { version = "1.36", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "cors"] }

# Database
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }

# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

# Error handling
thiserror = "1.0"
anyhow = "1.0"

# Config & logging
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# Utilities
uuid = { version = "1.7", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }
validator = { version = "0.18", features = ["derive"] }

Why these choices:

  • axum - Ergonomic, built on Tokio, best performance
  • sqlx - Compile-time checked SQL queries
  • thiserror - Clean error type definitions

Step 2: Database Schema and Models

Prompt to Claude:

"Create a SQLx migration for a 'users' table with id (uuid), email (unique), name, and timestamps. Then create a Rust User model with proper derives for JSON serialization and database mapping."

# Start PostgreSQL
docker run -d \
  --name postgres \
  -e POSTGRES_PASSWORD=devpass \
  -e POSTGRES_DB=rustapi \
  -p 5432:5432 \
  postgres:16-alpine

# Create migrations directory
mkdir -p migrations

Claude generates migrations/20260212_create_users.sql:

-- Create users table
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Index for email lookups
CREATE INDEX idx_users_email ON users(email);

-- Trigger for updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at();

Claude generates src/models/user.rs:

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

#[derive(Debug, Clone, FromRow, Serialize)]
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, Validate)]
pub struct CreateUser {
    #[validate(email(message = "Invalid email format"))]
    pub email: String,
    
    #[validate(length(min = 1, max = 255, message = "Name must be 1-255 characters"))]
    pub name: String,
}

#[derive(Debug, Deserialize, Validate)]
pub struct UpdateUser {
    #[validate(email(message = "Invalid email format"))]
    pub email: Option<String>,
    
    #[validate(length(min = 1, max = 255))]
    pub name: Option<String>,
}

Why FromRow: SQLx uses this to map database rows to structs at compile time.


Step 3: Error Handling

Prompt to Claude:

"Create a custom error type using thiserror that handles database errors, validation errors, and not found errors. Make it implement IntoResponse for Axum so errors automatically convert to proper HTTP responses."

Claude generates src/error.rs:

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

#[derive(thiserror::Error, Debug)]
pub enum ApiError {
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
    
    #[error("Validation error: {0}")]
    Validation(String),
    
    #[error("Not found: {0}")]
    NotFound(String),
    
    #[error("Internal server error")]
    Internal,
}

// This converts our error type to HTTP responses
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::Database(ref e) => {
                tracing::error!("Database error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "Database error")
            }
            ApiError::Validation(ref msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
            ApiError::NotFound(ref msg) => (StatusCode::NOT_FOUND, msg.as_str()),
            ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
        };

        let body = Json(json!({
            "error": message,
        }));

        (status, body).into_response()
    }
}

pub type Result<T> = std::result::Result<T, ApiError>;

Why this works: Axum automatically calls into_response() when handlers return errors, giving clean HTTP responses.


Step 4: Repository Pattern

Prompt to Claude:

"Create a UserRepository struct that handles all database operations for users. Use SQLx's compile-time query checking with the query_as! macro. Include CRUD operations."

Claude generates src/repository/user_repository.rs:

use crate::{error::Result, models::user::*};
use sqlx::PgPool;
use uuid::Uuid;

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

impl UserRepository {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }

    pub async fn create(&self, input: CreateUser) -> Result<User> {
        // sqlx checks this query at compile time!
        let user = sqlx::query_as!(
            User,
            r#"
            INSERT INTO users (email, name)
            VALUES ($1, $2)
            RETURNING id, email, name, created_at, updated_at
            "#,
            input.email,
            input.name
        )
        .fetch_one(&self.pool)
        .await?;

        Ok(user)
    }

    pub async fn find_by_id(&self, id: Uuid) -> Result<User> {
        let user = sqlx::query_as!(
            User,
            "SELECT id, email, name, created_at, updated_at FROM users WHERE id = $1",
            id
        )
        .fetch_optional(&self.pool)
        .await?
        .ok_or_else(|| crate::error::ApiError::NotFound(format!("User {} not found", id)))?;

        Ok(user)
    }

    pub async fn list(&self, limit: i64, offset: i64) -> Result<Vec<User>> {
        let users = sqlx::query_as!(
            User,
            "SELECT id, email, name, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
            limit,
            offset
        )
        .fetch_all(&self.pool)
        .await?;

        Ok(users)
    }

    pub async fn update(&self, id: Uuid, input: UpdateUser) -> Result<User> {
        // Only update fields that are provided
        let user = sqlx::query_as!(
            User,
            r#"
            UPDATE users
            SET email = COALESCE($2, email),
                name = COALESCE($3, name)
            WHERE id = $1
            RETURNING id, email, name, created_at, updated_at
            "#,
            id,
            input.email,
            input.name
        )
        .fetch_optional(&self.pool)
        .await?
        .ok_or_else(|| crate::error::ApiError::NotFound(format!("User {} not found", id)))?;

        Ok(user)
    }

    pub async fn delete(&self, id: Uuid) -> Result<()> {
        let result = sqlx::query!("DELETE FROM users WHERE id = $1", id)
            .execute(&self.pool)
            .await?;

        if result.rows_affected() == 0 {
            return Err(crate::error::ApiError::NotFound(format!("User {} not found", id)));
        }

        Ok(())
    }
}

Key insight: query_as! validates SQL against your actual database schema during compilation.


Step 5: HTTP Handlers

Prompt to Claude:

"Create Axum handlers for CRUD operations on users. Use extractors for JSON body, path parameters, and query parameters. Include validation using the validator crate."

Claude generates src/handlers/user_handlers.rs:

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    Json,
};
use serde::Deserialize;
use uuid::Uuid;
use validator::Validate;

use crate::{
    error::{ApiError, Result},
    models::user::*,
    repository::user_repository::UserRepository,
};

#[derive(Deserialize)]
pub struct Pagination {
    #[serde(default = "default_limit")]
    limit: i64,
    #[serde(default)]
    offset: i64,
}

fn default_limit() -> i64 { 20 }

// Create user
pub async fn create_user(
    State(repo): State<UserRepository>,
    Json(input): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>)> {
    // Validate input
    input.validate()
        .map_err(|e| ApiError::Validation(e.to_string()))?;

    let user = repo.create(input).await?;
    Ok((StatusCode::CREATED, Json(user)))
}

// Get user by ID
pub async fn get_user(
    State(repo): State<UserRepository>,
    Path(id): Path<Uuid>,
) -> Result<Json<User>> {
    let user = repo.find_by_id(id).await?;
    Ok(Json(user))
}

// List users with pagination
pub async fn list_users(
    State(repo): State<UserRepository>,
    Query(pagination): Query<Pagination>,
) -> Result<Json<Vec<User>>> {
    let users = repo.list(pagination.limit, pagination.offset).await?;
    Ok(Json(users))
}

// Update user
pub async fn update_user(
    State(repo): State<UserRepository>,
    Path(id): Path<Uuid>,
    Json(input): Json<UpdateUser>,
) -> Result<Json<User>> {
    input.validate()
        .map_err(|e| ApiError::Validation(e.to_string()))?;

    let user = repo.update(id, input).await?;
    Ok(Json(user))
}

// Delete user
pub async fn delete_user(
    State(repo): State<UserRepository>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode> {
    repo.delete(id).await?;
    Ok(StatusCode::NO_CONTENT)
}

Why State extraction: Axum shares the repository across all handlers without Arc<Mutex<>> complexity.


Step 6: Router Setup

Prompt to Claude:

"Create the main.rs file that sets up the Axum router with all handlers, configures CORS and tracing middleware, connects to PostgreSQL, and starts the server."

Claude generates src/main.rs:

mod error;
mod handlers;
mod models;
mod repository;

use axum::{
    routing::{delete, get, post, put},
    Router,
};
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
use tower_http::{
    cors::{Any, CorsLayer},
    trace::TraceLayer,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Load environment variables
    dotenvy::dotenv().ok();

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

    // Database connection
    let database_url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "postgres://postgres:devpass@localhost/rustapi".to_string());

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .acquire_timeout(Duration::from_secs(3))
        .connect(&database_url)
        .await?;

    tracing::info!("Connected to database");

    // Run migrations
    sqlx::migrate!("./migrations").run(&pool).await?;
    tracing::info!("Migrations complete");

    // Create repository
    let user_repo = repository::user_repository::UserRepository::new(pool);

    // Build router
    let app = Router::new()
        .route("/health", get(health_check))
        .route("/users", post(handlers::user_handlers::create_user))
        .route("/users", get(handlers::user_handlers::list_users))
        .route("/users/:id", get(handlers::user_handlers::get_user))
        .route("/users/:id", put(handlers::user_handlers::update_user))
        .route("/users/:id", delete(handlers::user_handlers::delete_user))
        .layer(
            CorsLayer::new()
                .allow_origin(Any)
                .allow_methods(Any)
                .allow_headers(Any),
        )
        .layer(TraceLayer::new_for_http())
        .with_state(user_repo);

    // Start server
    let addr = std::env::var("BIND_ADDRESS")
        .unwrap_or_else(|_| "0.0.0.0:3000".to_string());
    
    let listener = tokio::net::TcpListener::bind(&addr).await?;
    tracing::info!("Server listening on {}", addr);

    axum::serve(listener, app).await?;

    Ok(())
}

async fn health_check() -> &'static str {
    "OK"
}

Why migrate! macro: Embeds migrations in the binary, ensures database schema matches code.


Step 7: Environment Setup

Create .env file:

DATABASE_URL=postgres://postgres:devpass@localhost/rustapi
BIND_ADDRESS=0.0.0.0:3000
RUST_LOG=rust_api=debug,tower_http=debug

If compilation fails:

  • Error: "can't find migrations": Run cargo sqlx prepare to generate offline query data
  • Error: "database doesn't exist": Ensure PostgreSQL is running and database is created

Verification

# Build and run
cargo build --release
cargo run

# In another Terminal, test the API
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","name":"Test User"}'

# Expected response
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "email": "test@example.com",
  "name": "Test User",
  "created_at": "2026-02-12T10:30:00Z",
  "updated_at": "2026-02-12T10:30:00Z"
}

# List users
curl http://localhost:3000/users

# Get specific user
curl http://localhost:3000/users/123e4567-e89b-12d3-a456-426614174000

Performance: Should handle 20k+ requests/second on modest hardware (4 CPU cores).


What You Learned

  • Claude 4.5 accelerates Rust API development by generating idiomatic code
  • Axum + SQLx provides type-safe database operations with compile-time checks
  • Proper error handling converts internal errors to clean HTTP responses
  • Repository pattern separates database logic from HTTP handlers

Production checklist:

  • Add rate limiting (tower-governor crate)
  • Implement authentication (JWT with jsonwebtoken)
  • Add request ID tracking for debugging
  • Set up health checks for Kubernetes/Docker

Limitations:

  • This uses basic error handling; production needs request IDs and structured logs
  • No caching layer; add Redis for high-traffic endpoints
  • Validation is simple; complex business rules need dedicated service layer

Claude 4.5 Pro Tips

Best prompts for Rust development:

  1. "Generate with explanations":

    "Create a [feature] and explain why each design choice matters for Rust's ownership model"

  2. "Check my code":

    "Review this Rust code for common mistakes: unnecessary clones, missing error handling, or non-idiomatic patterns"

  3. "Update dependencies":

    "Update this Cargo.toml to latest stable versions and flag any breaking changes"

  4. "Production-ready":

    "Add proper error handling, logging, and metrics to this function for production use"

What Claude excels at:

  • Generating boilerplate (models, migrations, handlers)
  • Explaining borrow checker errors
  • Suggesting idiomatic Rust patterns
  • Writing comprehensive tests

What to double-check:

  • Security implications (always review auth/validation code)
  • Performance characteristics (Claude may not optimize for your specific use case)
  • Error messages (ensure they're user-friendly, not just type-correct)

Tested with Rust 1.76.0, Axum 0.7.4, SQLx 0.7.3, PostgreSQL 16, macOS Sonoma & Ubuntu 24.04

Development time: ~45 minutes with Claude 4.5 assistance vs. 3-4 hours manual implementation