Axum Framework Guide
Applies to: Axum 0.7+, Rust Web APIs, Microservices Complements:
.claude/skills/rust-guide/SKILL.md
Core Principles
- •Tower-First: Axum is built on Tower; embrace
Service,Layer, and middleware composition - •Type-Safe Extraction: Use compile-time extractors for request data -- no manual parsing
- •Async Throughout: All handlers are async; never block the Tokio runtime
- •Modular Routing: Compose routers with
mergeandnest; separate public from protected routes - •Structured Errors: Implement
IntoResponsefor custom error types; return appropriate HTTP status codes
Project Structure
code
myproject/ ├── Cargo.toml ├── src/ │ ├── main.rs # Entry point (thin: env, tracing, bind, serve) │ ├── lib.rs # Module declarations │ ├── config.rs # Configuration loading (env-based) │ ├── routes/ │ │ ├── mod.rs # Router composition (create_router) │ │ ├── users.rs # User-related route definitions │ │ └── health.rs # Health check route │ ├── handlers/ │ │ ├── mod.rs │ │ └── users.rs # Handler functions (thin: extract, call service, respond) │ ├── models/ │ │ ├── mod.rs │ │ └── user.rs # Domain types, DTOs, validation │ ├── services/ │ │ ├── mod.rs │ │ └── user_service.rs # Business logic layer │ ├── repositories/ │ │ ├── mod.rs │ │ └── user_repository.rs # Database access layer │ ├── extractors/ │ │ ├── mod.rs │ │ └── auth.rs # Custom extractors (AuthUser, Claims) │ ├── middleware/ │ │ ├── mod.rs │ │ └── logging.rs # Custom Tower middleware │ └── errors/ │ ├── mod.rs │ └── app_error.rs # AppError enum + IntoResponse ├── tests/ │ └── integration_tests.rs └── migrations/
Architectural rules:
- •Handlers are thin: extract request data, call service, return response
- •Services contain business logic; they call repositories
- •Repositories handle database access only
- •Models define domain types and DTOs with validation
- •Errors are defined per-layer and composed with
#[from]
Dependencies (Cargo.toml)
toml
[dependencies]
# Web framework
axum = { version = "0.7", features = ["macros"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
tokio = { version = "1.0", features = ["full"] }
tower = { version = "0.4", features = ["full"] }
tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Database
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
# Validation
validator = { version = "0.16", features = ["derive"] }
# Error handling
thiserror = "1.0"
anyhow = "1.0"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Configuration
config = "0.14"
dotenvy = "0.15"
# Utilities
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
[dev-dependencies]
axum-test = "14.0"
mockall = "0.12"
Application Entry Point
rust
// src/main.rs
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "myproject=debug,tower_http=debug".into()))
.with(tracing_subscriber::fmt::layer())
.init();
let config = config::Config::load()?;
let pool = sqlx::PgPool::connect(&config.database_url).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let state = AppState::new(pool, config.clone());
let app = routes::create_router(state);
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
tracing::info!("Starting server on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c().await
.expect("Failed to install CTRL+C signal handler");
tracing::info!("Shutdown signal received");
}
Routing
Router Composition
rust
// src/routes/mod.rs
pub fn create_router(state: AppState) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let public_routes = Router::new()
.route("/health", get(handlers::health::health_check))
.route("/api/v1/auth/register", post(handlers::users::register))
.route("/api/v1/auth/login", post(handlers::users::login));
let protected_routes = Router::new()
.route("/api/v1/users", get(handlers::users::list_users))
.route("/api/v1/users/:id", get(handlers::users::get_user))
.route("/api/v1/users/:id", put(handlers::users::update_user))
.route("/api/v1/users/:id", delete(handlers::users::delete_user))
.layer(middleware::from_fn_with_state(
state.clone(),
app_middleware::auth::auth_middleware,
));
Router::new()
.merge(public_routes)
.merge(protected_routes)
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(cors)
.with_state(state)
}
Routing Rules
- •Use
mergefor flat route composition; usenestfor path-prefix grouping - •Apply authentication middleware only to protected route groups
- •Layer order matters: outermost layer runs first (CORS, Tracing, then Compression)
- •Always call
.with_state(state)last, after all routes and layers
Handlers
Handlers are async functions that receive extractors and return impl IntoResponse.
rust
// src/handlers/users.rs
pub async fn register(
State(state): State<AppState>,
Json(dto): Json<CreateUserDto>,
) -> AppResult<(StatusCode, Json<AuthResponse>)> {
let service = UserService::new(state.pool, state.config);
let response = service.register(dto).await?;
Ok((StatusCode::CREATED, Json(response)))
}
pub async fn list_users(
State(state): State<AppState>,
Query(pagination): Query<PaginationQuery>,
_auth_user: AuthUser,
) -> AppResult<Json<Vec<UserResponse>>> {
let service = UserService::new(state.pool, state.config);
let users = service.list_users(pagination.page, pagination.per_page).await?;
Ok(Json(users))
}
Handler Rules
- •Keep handlers under 15 lines; delegate logic to services
- •Always validate input via extractors or explicit
.validate()calls - •Use
AppResult<T>(a type alias forResult<T, AppError>) as the return type - •Extract
AuthUsereven if unused (_auth_user) to enforce authentication - •Return appropriate status codes:
CREATEDfor POST,NO_CONTENTfor DELETE
Extractors
Built-in Extractors
| Extractor | Purpose | Example |
|---|---|---|
State(state) | Shared application state | State(state): State<AppState> |
Json(body) | JSON request body | Json(dto): Json<CreateUserDto> |
Path(id) | URL path parameters | Path(id): Path<Uuid> |
Query(params) | Query string parameters | Query(q): Query<PaginationQuery> |
Extension(ext) | Request extensions | Extension(user): Extension<User> |
Custom Extractor
rust
// src/extractors/auth.rs
pub struct AuthUser {
pub user_id: Uuid,
pub email: String,
pub role: String,
}
#[async_trait]
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| AppError::Unauthorized("Missing authorization header".into()))?;
let claims = verify_token(bearer.token(), &state.config.jwt_secret)?;
Ok(AuthUser {
user_id: claims.sub,
email: claims.email,
role: claims.role,
})
}
}
Extractor Rules
- •Implement
FromRequestParts(notFromRequest) when you only need headers/state - •Use
FromRequestonly when you need the body (consumes it) - •Extractor order in function signatures matters: body-consuming extractors must be last
- •Always return your
AppErrortype as theRejection
State Management
rust
#[derive(Clone)]
pub struct AppState {
pub pool: sqlx::PgPool,
pub config: Config,
}
impl AppState {
pub fn new(pool: sqlx::PgPool, config: Config) -> Self {
Self { pool, config }
}
}
State Rules
- •
AppStatemust implementClone(Axum requirement) - •Use
sqlx::PgPooldirectly (it isArc-wrapped internally) - •For mutable shared state, wrap in
Arc<tokio::sync::RwLock<T>> - •Avoid
std::sync::Mutexin async state; usetokio::sync::MutexorRwLock - •Keep state lean: connection pools, config, caches -- not request-scoped data
Error Handling
rust
// src/errors/app_error.rs
#[derive(Error, Debug)]
pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal server error")]
Internal(#[from] anyhow::Error),
#[error("Database error")]
Database(#[from] sqlx::Error),
}
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(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
AppError::Internal(err) => {
tracing::error!("Internal error: {:?}", err);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into())
}
AppError::Database(err) => {
tracing::error!("Database error: {:?}", err);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error".into())
}
};
let body = Json(json!({
"success": false,
"error": { "code": status.as_u16(), "message": message }
}));
(status, body).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
Error Handling Rules
- •Always implement
IntoResponsefor your error type - •Never leak internal details (database errors, stack traces) to clients
- •Log internal/database errors with
tracing::error!before mapping to generic messages - •Use
thiserrorfor enum definitions;anyhowfor internal propagation - •Define
AppResult<T>alias at the crate level for consistency
Middleware and Tower Integration
Route-Level Middleware
rust
// Apply auth middleware to a route group
let protected = Router::new()
.route("/api/v1/users", get(list_users))
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
Custom Middleware Function
rust
pub async fn auth_middleware(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, AppError> {
let token = request.headers()
.get(AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing token".into()))?;
verify_token(token, &state.config.jwt_secret)?;
Ok(next.run(request).await)
}
Tower Layers (tower-http)
rust
// Common layers -- add in create_router .layer(TraceLayer::new_for_http()) // Request/response logging .layer(CompressionLayer::new()) // Gzip response compression .layer(CorsLayer::new().allow_origin(Any)) // CORS headers .layer(TimeoutLayer::new(Duration::from_secs(30))) // Request timeout
Middleware Rules
- •Use
middleware::from_fn_with_statefor middleware needingAppState - •Use
middleware::from_fnfor stateless middleware - •Layer ordering: outermost layer executes first on request, last on response
- •Always add
TimeoutLayerto prevent slow requests from exhausting resources - •Use
TraceLayerfor observability in all environments
Commands
bash
# Development cargo run # Start server cargo watch -x run # Watch mode (requires cargo-watch) RUST_LOG=debug cargo run # With debug logging # Build cargo build --release # Production build cargo check # Fast type-check # Quality cargo fmt # Format code cargo clippy -- -D warnings # Lint with deny cargo audit # Vulnerability check # Testing cargo test # All tests cargo test --test integration_tests # Integration only # Database sqlx migrate run # Run migrations sqlx migrate add create_users # Create new migration
Best Practices Summary
- •Extractors: Use custom extractors for reusable validation;
FromRequestPartsfor headers,FromRequestfor body - •Error Handling: One
AppErrorenum per service; implementIntoResponse; log internal errors, sanitize client messages - •Performance: Use connection pooling (SQLx), enable compression (tower-http), add timeouts, use async throughout
- •Testing: Use
axum-testfor integration tests; mock repositories for unit tests; use a separate test database - •Security: Validate all inputs with
validator; use parameterized queries (SQLx); never expose internal errors
Advanced Topics
For detailed handler examples, database integration, authentication flows, WebSocket support, and testing patterns, see:
- •references/patterns.md -- Full CRUD handlers, repository pattern, JWT auth, WebSocket, integration testing