Rust + Axum AI Rules

Rules for Rust web services with Axum: tower middleware, sqlx for compile-time-checked queries, tokio runtime conventions, structured tracing, and ergonomic error types.

RustAxum#rust#axum#sqlx#tokioLast updated 2026-05-05
tune

Want to customize this rules file? Open the generator with this stack pre-loaded.

Open in generatorarrow_forward

Save at .cursor/rules/main.mdc

Rust + Axum

Project context

This is a Rust web service built with Axum on top of tokio. We use sqlx for compile-time-checked SQL, structured tracing for observability, and thiserror-derived error types that translate cleanly to HTTP responses.

Stack

  • Rust stable (latest)
  • Axum 0.7+
  • tokio 1.x (multi-thread runtime)
  • sqlx 0.8+ with Postgres
  • tower / tower-http for middleware
  • tracing + tracing-subscriber for logs
  • serde + serde_json for JSON
  • thiserror for error types
  • anyhow for ad-hoc errors in binaries (never in libs)

Folder structure

src/
  main.rs                — bootstrap (tracing, config, listener)
  app.rs                 — Axum router builder
  config.rs              — typed config from env
  error.rs               — top-level error type + IntoResponse
  state.rs               — shared AppState (DB pool, etc.)
  routes/
    mod.rs
    posts.rs
  domain/                — pure types
  service/               — business logic
  repo/                  — sqlx queries
  middleware/
migrations/              — sqlx migrations
tests/                   — integration tests via reqwest against a spawned server

Cargo workspace

If the project grows past one binary, split into workspace crates:

  • crates/core — domain, service
  • crates/web — Axum routes, handlers
  • crates/db — sqlx repo
  • crates/bin-api — binary that wires it all up

Don't crate-split prematurely; do it when compile times or domain boundaries demand it.

Async-first

  • #[tokio::main] on main — multi-threaded runtime by default
  • All I/O is async; never std::fs / std::net in request paths
  • Use tokio::task::spawn for fire-and-forget background work; track JoinHandles when correctness depends on completion

State

#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
    pub config: Arc<Config>,
}
  • PgPool is cheap to clone (Arc internally) — no need to wrap it manually
  • Pass State<AppState> to handlers
  • Avoid lazy_static! / OnceCell for things you can put in AppState

Handlers

pub async fn list_posts(
    State(state): State<AppState>,
    Query(params): Query<ListParams>,
) -> Result<Json<Vec<PostDto>>, AppError> {
    let posts = repo::list_posts(&state.db, params.limit).await?;
    Ok(Json(posts.into_iter().map(PostDto::from).collect()))
}
  • Return Result<T, AppError> from every handler; let the error type implement IntoResponse
  • Use Axum extractors (State, Path, Query, Json) — they're typed
  • Validate input with validator derives or by parsing into a stricter type

Errors

#[derive(thiserror::Error, Debug)]
pub enum AppError {
    #[error("not found")]
    NotFound,
    #[error("validation: {0}")]
    Validation(String),
    #[error(transparent)]
    Db(#[from] sqlx::Error),
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        match self {
            Self::NotFound => (StatusCode::NOT_FOUND, "Not found").into_response(),
            Self::Validation(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
            _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response(),
        }
    }
}
  • Never .unwrap() / .expect() in request paths — propagate via ?
  • Use thiserror for typed library errors; anyhow only at binary boundaries

Database (sqlx)

  • Use sqlx::query! / sqlx::query_as! for compile-time-checked queries
  • Set SQLX_OFFLINE=true in CI and commit .sqlx/ query metadata
  • Use query_as!(Post, "SELECT ...") to bind directly into structs
  • Wrap multi-step writes in pool.begin().await? transactions

Tracing

use tracing::{info, instrument};

#[instrument(skip(db))]
pub async fn create_post(db: &PgPool, dto: CreatePost) -> Result<Post, AppError> {
    info!("creating post");
    // ...
}
  • #[instrument] on service-layer fns; the tracing-subscriber JSON formatter for prod
  • Use tower-http::trace::TraceLayer middleware for per-request spans
  • Never log secrets

Patterns to follow

  • One mod.rs per route group; register all routes in app.rs
  • Use newtype wrappers for IDs (UserId(Uuid)) to avoid mixups
  • From / TryFrom impls for type conversions; not free functions
  • derive(Serialize, Deserialize) deliberately — separate DTOs from domain when shapes diverge

Patterns to avoid

  • .unwrap() / .expect() in request paths
  • Box<dyn Error> when you can use a typed error
  • async fn returning impl Future without Send when it must cross threads
  • Holding a PgPool connection across an await boundary — use a tx if you need to
  • Mutating shared state without synchronizationArc<Mutex<T>> or, better, message-passing

Testing

  • Unit tests inline in modules behind #[cfg(test)] mod tests
  • Integration tests in tests/ — spin up the app, hit it with reqwest
  • Use sqlx::test macro to provision a per-test DB

Tooling

  • cargo run
  • cargo test
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo fmt --all
  • sqlx migrate run
  • cargo sqlx prepare — for offline mode in CI

AI behavioral rules

  • Never .unwrap() or .expect() in request paths — propagate errors with ?
  • Always thread &PgPool (or a &mut Transaction) into repo functions; don't reach into global state
  • Use sqlx::query! macros for compile-time-checked queries
  • Define one AppError enum with IntoResponse; map domain errors there, not in handlers
  • Use #[instrument] on service-layer functions
  • Don't add a dependency without justifying it — Rust's compile times are real
  • Run cargo test, cargo clippy, and cargo fmt --check before declaring a task done

Frequently asked

How do I use this Axum rules file with Cursor?

Pick "Cursor (.cursor/rules/*.mdc)" from the format dropdown above and click Copy. Save it at .cursor/rules/main.mdc in your project root and restart Cursor. The legacy .cursorrules format still works if you're on an older Cursor version — pick that option instead.

Can I use this with Claude Code (CLAUDE.md)?

Yes — pick "Claude Code (CLAUDE.md)" from the format dropdown above and copy. Save the file as CLAUDE.md at your repo root. Claude Code reads it automatically on every session. For monorepos, you can also drop nested CLAUDE.md files in subdirectories — Claude merges them when working in those paths.

Where exactly do I put this file?

It depends on the AI tool. Cursor reads .cursorrules or .cursor/rules/*.mdc at the project root. Claude reads CLAUDE.md at the project root. Copilot reads .github/copilot-instructions.md. The "Save at" path under each format in the dropdown shows the exact location for the format you picked.

Can I customize these Axum rules for my project?

Yes — that's what the generator is for. Click "Open in generator" above and the wizard loads with this stack's defaults pre-selected. Toggle on or off the conventions you want, then re-export in your AI tool's format.

Will using this rules file slow down my AI tool?

No. Rules files count toward the model's context window but not toward latency in any noticeable way. The file is loaded once per session, not per token. The library files target 250–400 lines, well within every tool's recommended budget.

Should I commit this file to git?

Yes. The rules file is project documentation that benefits every developer using the AI tool. Commit it. The exception is personal-global settings (e.g. ~/.claude/CLAUDE.md) which are user-scoped and stay out of the repo.