
Axum Code Review
- 63 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
axum-code-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- axum-code-review
- AI & Agent Building
- AI-coding skill
Axum Code Review by the numbers
- 63 all-time installs (skills.sh)
- Ranked #6,243 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill axum-code-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 63 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Axum Code Review
Review Workflow
1. Check Cargo.toml — Note axum version (0.6 vs 0.7+ have different patterns), Rust edition (2021 vs 2024), tower, tower-http features. Edition 2024 changes RPIT lifetime capture in handler return types and removes the need for async-trait in custom extractors. 2. Check routing — Route organization, method routing, nested routers 3. Check extractors — Order matters (body extractors must be last), correct types 4. Check state — Shared state via State<T>, not global mutable state 5. Check error handling — IntoResponse implementations, error types
Gates (before reporting findings)
Run in order. Do not write a finding until the step that applies has passed.
1. Version and edition on disk — Pass when: You have read the relevant Cargo.toml (crate or workspace root) and can state axum (and related tower/tower-http) versions and Rust edition. Then apply 0.6 vs 0.7+ or Edition 2024–specific checklist items only when that file supports them.
2. Per-finding evidence — Pass when: Each issue cites [FILE:LINE] from the current tree for the handler, router, layer, or type under review (not from memory, docs-only, or another branch).
3. Category check vs protocol — Pass when: For the finding type (routing conflict, extractor order, error leak, middleware order, etc.), you ran the matching checks from the review-verification-protocol skill (e.g. full handler signature for extractor order; surrounding error mapping before “raw error to client”). Then add the finding.
4. Output shape — Pass when: The report lines match Output Format below (severity + description).
Output Format
Report findings as:
[FILE:LINE] ISSUE_TITLE
Severity: Critical | Major | Minor | Informational
Description of the issue and why it matters.Quick Reference
| Issue Type | Reference |
|---|---|
| Route definitions, nesting, method routing | references/routing.md |
| State, Path, Query, Json, body extractors | references/extractors.md |
| Tower middleware, layers, error handling | references/middleware.md |
Review Checklist
Routing
- [ ] Routes organized by domain (nested routers for
/api/users,/api/orders) - [ ] Fallback handlers defined for 404s
- [ ] Method routing explicit (
.get(),.post(), not.route()with manual method matching) - [ ] No route conflicts (overlapping paths with different extractors)
Extractors
- [ ] Body-consuming extractors (
Json,Form,Bytes) are the LAST parameter - [ ]
State<T>requiresT: Clone— typicallyArc<AppState>or directClonederive - [ ]
Path<T>parameter types match the route definition - [ ]
Query<T>fields areOptionfor optional query params with#[serde(default)] - [ ] Custom extractors implement
FromRequestParts(not body) orFromRequest(body) - [ ] Edition 2024: Custom extractors use native
async fnin trait impls (no#[async_trait]needed forFromRequest/FromRequestParts)
State Management
- [ ] Application state shared via
State<T>, not global mutable statics - [ ] Database pool in state (not created per-request)
- [ ] State contains only shared resources (pool, config, channels), not request-specific data
- [ ]
Clonederived or manually implemented on state type - [ ] Edition 2024: Shared static state uses
LazyLockfrom std (notonce_cell::sync::Lazyorlazy_static!)
Error Handling
- [ ] Handler errors implement
IntoResponsefor proper HTTP error codes - [ ] Internal errors don't leak to clients (no raw error messages in 500 responses)
- [ ] Error responses use consistent format (JSON error body with code/message)
- [ ]
Result<impl IntoResponse, AppError>pattern used for handlers - [ ] Edition 2024: Handler return types
-> impl IntoResponsecapture all in-scope lifetimes by default; use+ use<>to opt out of capturing request lifetimes when returning owned data
Middleware
- [ ] Tower layers applied in correct order (outer runs first on request, last on response)
- [ ]
tower-httpused for common concerns (CORS, compression, tracing, timeout) - [ ] Request-scoped data passed via extensions, not global state
- [ ] Middleware errors don't panic — they return error responses
- [ ] Edition 2024: Middleware using
#[async_trait]can migrate to nativeasync fnin trait impls
Severity Calibration
Critical
- Body extractor not last in handler parameters (silently consumes body, later extractors fail)
- SQL injection via path/query parameters passed directly to queries
- Internal error details leaked to clients (stack traces, database errors)
- Missing authentication middleware on protected routes
Major
- Global mutable state instead of
State<T>(race conditions) - Missing error type conversion (raw
sqlx::Errorreturned to client) - Missing request timeout (handlers can hang indefinitely)
- Route conflicts causing unexpected 405s
- Edition 2024:
async-traitstill used forFromRequest/FromRequestPartswhen native async fn works
Minor
- Manual route method matching instead of
.get(),.post() - Missing fallback handler (default 404 is plain text, not JSON)
- Middleware applied per-route when it should be global (or vice versa)
- Missing
tower-http::tracefor request logging - Edition 2024:
once_cell::sync::Lazyorlazy_static!used wherestd::sync::LazyLockworks
Informational
- Suggestions to use
tower-httplayers for common concerns - Router organization improvements
- Suggestions to add OpenAPI documentation via
utoipaoraide
Valid Patterns (Do NOT Flag)
- `#[axum::debug_handler]` on handlers — Debugging aid that improves compile error messages
- `Extension<T>` for middleware-injected data — Valid pattern for request-scoped values
- Returning `impl IntoResponse` from handlers — More flexible than concrete types
- `Router::new()` per module, merged in main — Standard organization pattern
- `ServiceBuilder` for layer composition — Tower pattern, not over-engineering
- `axum::serve` with `TcpListener` — Standard axum 0.7+ server setup
- Native `async fn` in `FromRequest`/`FromRequestParts` impls —
async-traitcrate no longer needed (stable since Rust 1.75) - `+ use<'a>` on handler return types — Edition 2024 precise capture syntax for RPIT
- `std::sync::LazyLock` for shared static state — Replaces
once_cell/lazy_static(stable since Rust 1.80)
Before Submitting Findings
Complete Gates (before reporting findings) and load the review-verification-protocol skill for category-specific checks before any issue is final.
Extractors
Extractor Ordering
Body-consuming extractors (Json, Form, Bytes, String) must be the LAST parameter. The HTTP body can only be consumed once.
// BAD - Json consumes body before Path can extract
async fn handler(Json(body): Json<CreateUser>, Path(id): Path<u64>) { ... }
// GOOD - non-body extractors first, body extractor last
async fn handler(Path(id): Path<u64>, Json(body): Json<CreateUser>) { ... }Common Extractors
State
Shared application state. The type must implement Clone.
#[derive(Clone)]
struct AppState {
pool: PgPool, // PgPool is Clone (it's an Arc internally)
config: Arc<Config>, // wrap non-Clone types in Arc
}
async fn handler(State(state): State<AppState>) -> impl IntoResponse {
let users = query_as!(User, "SELECT ...").fetch_all(&state.pool).await?;
Json(users)
}Path
Extract path parameters. Type must implement Deserialize.
// Single parameter
async fn get_user(Path(id): Path<Uuid>) -> impl IntoResponse { ... }
// Multiple parameters
async fn get_comment(
Path((post_id, comment_id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse { ... }
// Named parameters via struct
#[derive(Deserialize)]
struct CommentPath {
post_id: Uuid,
comment_id: Uuid,
}
async fn get_comment(Path(path): Path<CommentPath>) -> impl IntoResponse { ... }Query
Extract query string parameters.
#[derive(Deserialize)]
struct ListParams {
#[serde(default = "default_page")]
page: u32,
#[serde(default = "default_per_page")]
per_page: u32,
search: Option<String>,
}
fn default_page() -> u32 { 1 }
fn default_per_page() -> u32 { 20 }
async fn list_users(Query(params): Query<ListParams>) -> impl IntoResponse { ... }Json
Deserializes JSON request body. Must be the last extractor.
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
async fn create_user(
State(state): State<AppState>,
Json(input): Json<CreateUser>,
) -> Result<impl IntoResponse, AppError> {
let user = insert_user(&state.pool, &input).await?;
Ok((StatusCode::CREATED, Json(user)))
}Extension
For request-scoped data injected by middleware (e.g., authenticated user).
// Middleware injects
req.extensions_mut().insert(AuthUser { id: user_id });
// Handler extracts
async fn handler(Extension(user): Extension<AuthUser>) -> impl IntoResponse { ... }Custom Extractors and async fn in Traits (Edition 2024)
Since Rust 1.75, async fn is stable in trait definitions and implementations. Custom extractors no longer need #[async_trait].
// BAD (pre-1.75 / unnecessary dependency)
use async_trait::async_trait;
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// ...
}
}
// GOOD (Rust 1.75+ / edition 2024)
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let token = parts.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
// validate token...
Ok(AuthUser { id: user_id })
}
}Note: #[async_trait] is still needed when using dyn Trait with async extractors (trait objects require the indirection).
RPIT Lifetime Capture in Handlers (Edition 2024)
In edition 2024, -> impl Trait captures ALL in-scope lifetimes by default. For axum handlers returning owned data, this usually doesn't matter. But when a helper function returns impl IntoResponse and has lifetime parameters, it may over-capture:
// Edition 2024: this captures 'a even though the return is fully owned
fn render_page<'a>(title: &'a str) -> impl IntoResponse {
Html(format!("<h1>{title}</h1>"))
}
// If over-capture causes lifetime issues, use precise capture syntax
fn render_page<'a>(title: &'a str) -> impl IntoResponse + use<> {
Html(format!("<h1>{title}</h1>"))
}Most axum handlers take owned extractors and return owned data, so RPIT capture changes are low-impact. Watch for helper functions with reference parameters returning impl IntoResponse.
Error Handling Pattern
Handlers should return Result<impl IntoResponse, AppError> where AppError implements IntoResponse.
#[derive(Debug)]
enum AppError {
NotFound(String),
Internal(anyhow::Error),
Validation(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
Self::Internal(err) => {
tracing::error!(error = %err, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
}
Self::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
};
(status, Json(json!({"error": message}))).into_response()
}
}
// Automatic conversion from sqlx errors
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => Self::NotFound("resource not found".into()),
other => Self::Internal(other.into()),
}
}
}Review Questions
1. Are body-consuming extractors the last parameter? 2. Is State<T> used for shared resources (not per-request creation)? 3. Do handler errors implement IntoResponse with appropriate status codes? 4. Are internal error details hidden from clients? 5. Are Path types aligned with route parameter definitions? 6. Are Query fields optional where the query param is optional? 7. Are custom FromRequest/FromRequestParts impls using native async fn instead of #[async_trait]? 8. Do helper functions returning impl IntoResponse with lifetime params need + use<> precise capture?
Middleware
Tower Layer Pattern
Axum uses Tower for middleware. Layers wrap services to add cross-cutting concerns.
use tower_http::{
cors::CorsLayer,
compression::CompressionLayer,
timeout::TimeoutLayer,
trace::TraceLayer,
};
let app = Router::new()
.route("/api/health", get(health))
.nest("/api/users", users::router())
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(30)))
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive()) // configure properly for production
)
.with_state(state);Layer Ordering
Layers execute in reverse order of how they're added. The last .layer() call runs first on the request and last on the response.
Router::new()
.layer(A) // runs 3rd on request, 1st on response
.layer(B) // runs 2nd on request, 2nd on response
.layer(C) // runs 1st on request, 3rd on responseWith ServiceBuilder, the order is top-to-bottom (more intuitive):
ServiceBuilder::new()
.layer(C) // runs 1st on request
.layer(B) // runs 2nd on request
.layer(A) // runs 3rd on requestCommon tower-http Layers
| Layer | Purpose |
|---|---|
TraceLayer | Request/response logging with tracing spans |
TimeoutLayer | Request timeout (returns 408 on timeout) |
CorsLayer | CORS headers |
CompressionLayer | Response compression (gzip, br, etc.) |
RequestBodyLimitLayer | Limit request body size |
SetRequestHeaderLayer | Add/override request headers |
PropagateHeaderLayer | Copy request headers to response |
Custom Middleware with axum::middleware
For request/response transformation with access to axum extractors:
use axum::middleware::{self, Next};
async fn auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
let token = req.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let user = validate_token(&state.pool, token).await?;
req.extensions_mut().insert(user);
Ok(next.run(req).await)
}
// Apply to specific routes
let protected = Router::new()
.route("/profile", get(profile))
.route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
let app = Router::new()
.route("/health", get(health)) // unprotected
.merge(protected)
.with_state(state);Tower Service Trait and async fn in Traits (Edition 2024)
Custom Tower Service implementations that previously required #[async_trait] can now use native async fn. However, the Tower Service trait itself uses poll_ready/call (not async fn), so this primarily applies to higher-level abstractions built on top of Tower.
For axum middleware specifically, axum::middleware::from_fn already uses plain async functions and is unaffected. The benefit appears when implementing custom FromRequestParts extractors used within middleware:
// GOOD (edition 2024) - no #[async_trait] needed
impl<S> FromRequestParts<S> for RateLimitInfo
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let ip = parts.headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
Ok(RateLimitInfo { client_ip: ip.to_string() })
}
}FFI and unsafe extern (Edition 2024)
If middleware integrates with C libraries (e.g., custom TLS, hardware security modules), edition 2024 requires unsafe extern:
// BAD (edition 2024 — won't compile)
extern "C" {
fn custom_tls_init() -> i32;
}
// GOOD (edition 2024)
unsafe extern "C" {
fn custom_tls_init() -> i32;
}Also, #[no_mangle] on exported FFI functions must become #[unsafe(no_mangle)].
Graceful Shutdown
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
async fn shutdown_signal() {
tokio::signal::ctrl_c().await.expect("failed to listen for ctrl+c");
tracing::info!("shutdown signal received");
}Review Questions
1. Are layers ordered correctly (especially auth before business logic)? 2. Is tower-http used for standard concerns (CORS, compression, tracing)? 3. Is request timeout configured for production? 4. Does custom middleware use from_fn_with_state for state access? 5. Is graceful shutdown implemented? 6. Are extractors used in middleware using native async fn instead of #[async_trait]? 7. Are FFI blocks in middleware written as unsafe extern "C" (edition 2024)? 8. Are #[no_mangle] attributes on exported functions written as #[unsafe(no_mangle)] (edition 2024)?
Routing
Basic Routing
use axum::{routing::{get, post, put, delete}, Router};
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/{id}", get(get_user).put(update_user).delete(delete_user))
.with_state(state);Note: axum 0.7.x uses {id} path syntax. Earlier versions used :id.
Nested Routers
Organize routes by domain. Each module can define its own router.
// users.rs
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list).post(create))
.route("/{id}", get(show).put(update).delete(destroy))
}
// main.rs
let app = Router::new()
.nest("/api/users", users::router())
.nest("/api/orders", orders::router())
.fallback(handle_404)
.with_state(state);Nested Router State
Nested routers must use the same state type or a subset. Use Router::with_state() to provide different state to nested routers:
// Sub-router with different state
let admin_router = Router::new()
.route("/stats", get(admin_stats))
.with_state(admin_state); // different state type
let app = Router::new()
.nest("/admin", admin_router) // already has its state
.with_state(app_state); // main app stateFallback Handlers
async fn handle_404() -> impl IntoResponse {
(StatusCode::NOT_FOUND, Json(json!({"error": "not found"})))
}
let app = Router::new()
.route("/api/health", get(health))
.fallback(handle_404);Route Conflicts
Routes conflict when two patterns can match the same path. axum panics at startup when this happens.
// CONFLICT - both match /users/123
.route("/users/{id}", get(get_user))
.route("/users/{name}", get(get_user_by_name))
// SOLUTION - differentiate by prefix or use query params
.route("/users/by-id/{id}", get(get_user))
.route("/users/by-name/{name}", get(get_user_by_name))Method Routing
// Explicit per-method routing (preferred)
.route("/items", get(list_items).post(create_item))
// Method router for custom handling
use axum::routing::MethodRouter;
fn items_router() -> MethodRouter<AppState> {
get(list_items)
.post(create_item)
.options(preflight)
}LazyLock for Static Route Configuration (Edition 2024)
Static route tables or regex patterns previously initialized with once_cell::sync::Lazy or lazy_static! should use std::sync::LazyLock (stable since Rust 1.80):
// BAD (unnecessary dependency)
use once_cell::sync::Lazy;
static ROUTE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/api/v\d+/").unwrap());
// GOOD (std library, no extra dependency)
use std::sync::LazyLock;
static ROUTE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^/api/v\d+/").unwrap());RPIT Capture in Router Functions (Edition 2024)
Functions returning Router are unaffected (concrete type). But helper functions returning impl IntoResponse used as fallback handlers or utility responses may over-capture lifetimes in edition 2024:
// Edition 2024: captures 'a even though response is owned
fn not_found_body<'a>(path: &'a str) -> impl IntoResponse {
Json(json!({"error": format!("not found: {path}")}))
}
// Fix with precise capture if needed
fn not_found_body<'a>(path: &'a str) -> impl IntoResponse + use<> {
Json(json!({"error": format!("not found: {path}")}))
}Review Questions
1. Are routes organized by domain using nested routers? 2. Is there a fallback handler for unmatched routes? 3. Are route methods explicit (.get(), .post())? 4. Are there any route conflicts (overlapping path patterns)? 5. Is with_state called with the correct state type? 6. Are static route patterns using std::sync::LazyLock instead of once_cell/lazy_static? 7. Do helper functions returning impl IntoResponse with lifetime params need + use<> precise capture?