axum
SKILL.md
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
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)
[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
// 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
// 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.
// 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
// 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
#[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
// 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
// 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
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)
// 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
# 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
External References
Weekly Installs
5
Repository
ar4mirez/samuelGitHub Stars
3
First Seen
14 days ago
Security Audits
Installed on
opencode5
gemini-cli5
codebuddy5
github-copilot5
codex5
kimi-cli5