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.
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
CreateUserRequestbut return aUserResponsewith 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:
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_alldeliberately. 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.
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.
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.
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_onewhen a row might not exist. Usefetch_optionalinstead 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
Fromimpls. Most database errors should map to a genericDatabaseErrorvariant 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
Resultthat Axum converts to HTTP. - Implement
IntoResponseon a customAppErrorenum 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.






Comments
Loading comments…