Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 058 / 100

Building a REST API with Axum and SQLx, explained simply

A full feature end to end.

Expona Admin7 min read

Introduction

Building a REST API with Axum and SQLx, explained simply, removes the guesswork from async Rust web services. If you've worked with NestJS or Express, you know the friction point: connecting HTTP handlers to a database, managing errors gracefully, and keeping the code readable. Axum and SQLx give us compile-time guarantees that Express never will.

In this guide, we'll walk through a complete, production-ready pattern that teams use in trading platforms, SaaS applications, and e-commerce backends. We're covering models, repositories, handlers, and error mapping—the four pillars that keep your Axum service maintainable as it scales.

Models: Structs with Serde and SQLx Derives

Your models are the contract between your database, your business logic, and the HTTP layer. We define them once, lean on derives, and let the compiler catch mismatches before runtime.

Start with a concrete example: imagine we're building a user service for a ride-hailing platform. We need to model a user record from the database and a request/response shape for the API.

rust
use serde::{Deserialize, Serialize};
use sqlx::FromRow;

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

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

#[derive(Debug, Serialize)]
pub struct UserResponse {
    pub id: i64,
    pub email: String,
    pub name: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

The FromRow derive from SQLx maps database columns to struct fields by name. Serialize and Deserialize from serde handle JSON encoding and decoding. This separation—request shape, database shape, response shape—keeps your API contract independent of your schema.

A few patterns we recommend:

  • Separate request and response types. Your API might accept CreateUserRequest but return a UserResponse with computed fields or redacted data.
  • Use chrono for timestamps. Store and parse as UTC. Serialize to ISO 8601 by default.
  • Keep models in their own module. As your service grows, models become a shared language across repository, handler, and test code.

Repository: Functions Using the Pool

The repository layer abstracts database access. Instead of scattering sqlx::query calls throughout your handlers, you centralize queries and return Rust types. This makes testing easier and changes to SQL isolated.

Here's a repository for our user service:

rust
use sqlx::PgPool;
use crate::models::{User, CreateUserRequest};

pub struct UserRepository {
    pool: PgPool,
}

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

    pub async fn create(&self, req: CreateUserRequest) -> Result<User, sqlx::Error> {
        let now = chrono::Utc::now();
        sqlx::query_as::<_, User>(
            "INSERT INTO users (email, name, created_at) VALUES ($1, $2, $3) RETURNING *"
        )
        .bind(&req.email)
        .bind(&req.name)
        .bind(now)
        .fetch_one(&self.pool)
        .await
    }

    pub async fn get_by_id(&self, id: i64) -> Result<Option<User>, sqlx::Error> {
        sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
            .bind(id)
            .fetch_optional(&self.pool)
            .await
    }

    pub async fn list(&self, limit: i64) -> Result<Vec<User>, sqlx::Error> {
        sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY created_at DESC LIMIT $1")
            .bind(limit)
            .fetch_all(&self.pool)
            .await
    }
}

The pool is initialized once at startup and shared across requests. Each query is type-checked at compile time if you use sqlx::query_as with the checked macro or enable offline mode.

Key patterns in repositories:

  • Return Result types from SQLx. Let the caller decide how to handle sqlx::Error.
  • Use fetch_one, fetch_optional, fetch_all deliberately. Panic if you mix them up; type safety helps but intent matters.
  • Bind parameters explicitly. Never interpolate user input into queries. bind() is your friend.

Handlers: Extract, Call Repository, Return Json

Handlers glue HTTP requests to your business logic. Axum's extractors make this pattern straightforward: extract the request data, validate it, call the repository, and return JSON or an error.

rust
use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use crate::models::{CreateUserRequest, UserResponse};
use crate::repository::UserRepository;

pub struct AppState {
    pub user_repo: UserRepository,
}

pub async fn create_user(
    State(state): State<std::sync::Arc<AppState>>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
    let user = state
        .user_repo
        .create(payload)
        .await
        .map_err(AppError::from)?;

    Ok((
        StatusCode::CREATED,
        Json(UserResponse {
            id: user.id,
            email: user.email,
            name: user.name,
            created_at: user.created_at,
        }),
    ))
}

pub async fn get_user(
    State(state): State<std::sync::Arc<AppState>>,
    Path(id): Path<i64>,
) -> Result<Json<UserResponse>, AppError> {
    let user = state
        .user_repo
        .get_by_id(id)
        .await
        .map_err(AppError::from)?
        .ok_or(AppError::UserNotFound)?;

    Ok(Json(UserResponse {
        id: user.id,
        email: user.email,
        name: user.name,
        created_at: user.created_at,
    }))
}

pub async fn list_users(
    State(state): State<std::sync::Arc<AppState>>,
) -> Result<Json<Vec<UserResponse>>, AppError> {
    let users = state
        .user_repo
        .list(50)
        .await
        .map_err(AppError::from)?;

    Ok(Json(
        users
            .into_iter()
            .map(|u| UserResponse {
                id: u.id,
                email: u.email,
                name: u.name,
                created_at: u.created_at,
            })
            .collect(),
    ))
}

The pattern is consistent: extract state and payload, await the repository, map errors, and return JSON with the appropriate HTTP status code. Axum's Result type automatically converts your error type into an HTTP response if you implement the right traits.

Errors: AppError Maps to HTTP Responses

Error handling in Axum is where many teams stumble. We define a custom error enum, implement IntoResponse, and suddenly all our handlers have consistent error responses.

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

#[derive(Debug)]
pub enum AppError {
    UserNotFound,
    EmailAlreadyExists,
    DatabaseError(String),
    ValidationError(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, error_msg) = match self {
            AppError::UserNotFound => (StatusCode::NOT_FOUND, "User not found"),
            AppError::EmailAlreadyExists => {
                (StatusCode::CONFLICT, "Email already registered")
            }
            AppError::DatabaseError(_) => {
                (StatusCode::INTERNAL_SERVER_ERROR, "Database error occurred")
            }
            AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, &msg),
        };

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

        (status, body).into_response()
    }
}

impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        match err {
            sqlx::Error::RowNotFound => AppError::UserNotFound,
            _ => AppError::DatabaseError(err.to_string()),
        }
    }
}

By implementing IntoResponse, Axum knows how to serialize your error into an HTTP response. The From implementation lets handlers use the ? operator on SQLx results.

Advantages of this approach:

  • Centralized error logic. One place to define what HTTP status each error gets.
  • Consistent JSON structure. Clients always receive the same error format.
  • Easier logging. Log the detailed error server-side; send a safe message to the client.

Wiring It All Together

Your main function brings it all together: create a pool, initialize the repository, set up routes, and start the server.

rust
use axum::{
    routing::{get, post},
    Router,
};
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;

#[tokio::main]
async fn main() {
    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL not set");

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

    let state = Arc::new(AppState {
        user_repo: UserRepository::new(pool),
    });

    let app = Router::new()
        .route("/users", post(create_user))
        .route("/users/:id", get(get_user))
        .route("/users", get(list_users))
        .with_state(state);

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service_with_connect_info::<std::net::SocketAddr>())
        .await
        .unwrap();
}

Common Mistakes

  • Forgetting to add #[derive(FromRow)] to models. You'll get a confusing error about missing fields. Check your derive macros first.
  • Using fetch_one when a row might not exist. Use fetch_optional instead to avoid panicking on empty results.
  • Not separating request and response models. This locks your API contract to your database schema; schema changes become API changes.
  • Passing the raw pool to handlers instead of the repository. Leaks SQL logic into your handler layer; harder to test and refactor.
  • Over-matching on SQLx errors in From impls. Most database errors should map to a generic DatabaseError variant unless you have a specific reason to distinguish them.

Key Takeaways

  • Use separate struct types for database rows (#[derive(FromRow)]), API requests, and API responses to keep concerns isolated.
  • Centralize all database queries in a repository layer; this makes testing easier and changes to SQL localized.
  • Handlers extract payload and state, call the repository, and return a Result that Axum converts to HTTP.
  • Implement IntoResponse on a custom AppError enum to map Rust errors to consistent HTTP responses.
  • This pattern scales from a single microservice to a multi-service platform; we use it across trading, travel, and SaaS backends.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles