The previous five parts built the foundation. Now we put it into production. This post builds a complete HTTP backend service using Axum – Tokio’s first-party web framework – with connection pooling via SQLx, structured middleware, graceful shutdown, and the patterns that make services reliable under real load.
Project Setup
# Cargo.toml
[package]
name = "backend-service"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "cors", "timeout", "limit"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio", "uuid", "time"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "1"Application State and Error Types
use std::sync::Arc;
use sqlx::PgPool;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
// Shared application state - cloned cheaply into each handler
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub config: Arc,
}
#[derive(Debug)]
pub struct Config {
pub database_url: String,
pub port: u16,
pub max_connections: u32,
}
// Unified error type for handlers
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal error")]
Internal(#[from] anyhow::Error),
}
// Convert errors to HTTP responses automatically
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Database(e) => {
tracing::error!("Database error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
}
AppError::Internal(e) => {
tracing::error!("Internal error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string())
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
} Database Connection Pool
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Result {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.min_connections(5) // keep min connections warm
.acquire_timeout(Duration::from_secs(3)) // fail fast on pool exhaustion
.idle_timeout(Duration::from_secs(600)) // close idle connections after 10min
.max_lifetime(Duration::from_secs(1800)) // recycle connections every 30min
.connect(database_url)
.await?;
// Run migrations on startup
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
} Routing and Handlers
use axum::{
Router,
routing::{get, post, put, delete},
extract::{Path, State, Json as ExtractJson},
response::Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
pub name: String,
pub created_at: time::OffsetDateTime,
}
#[derive(Debug, Deserialize)]
pub struct CreateUser {
pub email: String,
pub name: String,
}
// Handler returns Result
// Axum calls IntoResponse on both Ok and Err variants
async fn create_user(
State(state): State,
ExtractJson(payload): ExtractJson,
) -> Result<(StatusCode, Json), AppError> {
// Validate
if payload.email.is_empty() {
return Err(AppError::Validation("email is required".into()));
}
let user = sqlx::query_as::<_, User>(
"INSERT INTO users (id, email, name) VALUES ($1, $2, $3) RETURNING *"
)
.bind(Uuid::new_v4())
.bind(&payload.email)
.bind(&payload.name)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(user)))
}
async fn get_user(
State(state): State,
Path(user_id): Path,
) -> Result, AppError> {
let user = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE id = $1"
)
.bind(user_id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", user_id)))?;
Ok(Json(user))
}
async fn list_users(
State(state): State,
) -> Result>, AppError> {
let users = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY created_at DESC")
.fetch_all(&state.db)
.await?;
Ok(Json(users))
}
pub fn user_routes() -> Router {
Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user))
} Middleware Stack
use tower_http::{
trace::TraceLayer,
cors::CorsLayer,
timeout::TimeoutLayer,
limit::RequestBodyLimitLayer,
};
use axum::http::{Method, HeaderValue};
fn build_middleware_stack() -> tower::ServiceBuilder) -> std::future::Ready>>>> {
tower::ServiceBuilder::new()
// Request tracing - logs method, path, status, latency
.layer(TraceLayer::new_for_http())
// Global request timeout - prevents slow clients from holding connections
.layer(TimeoutLayer::new(Duration::from_secs(30)))
// Request body size limit - prevents large payload attacks
.layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) // 10MB
// CORS
.layer(
CorsLayer::new()
.allow_origin("https://yourdomain.com".parse::().unwrap())
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
)
}
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health_check))
.nest("/api/v1", user_routes())
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(30)))
.layer(RequestBodyLimitLayer::new(10 * 1024 * 1024))
}
async fn health_check(State(state): State) -> impl IntoResponse {
match sqlx::query("SELECT 1").fetch_one(&state.db).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "status": "ok" }))),
Err(_) => (StatusCode::SERVICE_UNAVAILABLE, Json(serde_json::json!({ "status": "degraded" }))),
}
} Graceful Shutdown
use tokio::net::TcpListener;
use tokio::signal;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let config = Arc::new(Config {
database_url: std::env::var("DATABASE_URL")?,
port: std::env::var("PORT").unwrap_or("8080".into()).parse()?,
max_connections: 20,
});
let db = create_pool(&config.database_url, config.max_connections).await?;
let state = AppState { db, config: Arc::clone(&config) };
let app = build_router(state);
let listener = TcpListener::bind(format!("0.0.0.0:{}", config.port)).await?;
tracing::info!("Listening on port {}", config.port);
// Serve with graceful shutdown
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
tracing::info!("Server shut down gracefully");
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => { tracing::info!("Received Ctrl+C"); },
_ = terminate => { tracing::info!("Received SIGTERM"); },
}
}Architecture Overview
flowchart TD
Client -->|HTTP Request| L[TcpListener]
L -->|accept| A[Axum Router]
A --> MW1[TraceLayer]
MW1 --> MW2[TimeoutLayer]
MW2 --> MW3[CorsLayer]
MW3 --> H[Handler fn]
H -->|query| P[PgPool]
P -->|connection| DB[(PostgreSQL)]
DB -->|result| P
P -->|result| H
H -->|AppError| E[IntoResponse]
H -->|Ok response| R[JSON Response]
E --> R
R --> Client
What Comes Next
Part 7 tackles error handling and cancellation safety – two areas where async Rust has specific behaviors that differ from synchronous code. Cancellation-safe futures, the select! macro in depth, and what happens to your data when a future is dropped mid-operation.
References
- Axum Documentation (https://docs.rs/axum/latest/axum/)
- SQLx Documentation (https://docs.rs/sqlx/latest/sqlx/)
- tower-http Documentation (https://docs.rs/tower-http/latest/tower_http/)
- Tokio Tutorial (https://tokio.rs/tokio/tutorial)
- Axum Examples Repository (https://github.com/tokio-rs/axum/tree/main/examples)
