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, servicecrates/web— Axum routes, handlerscrates/db— sqlx repocrates/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]onmain— multi-threaded runtime by default- All I/O is async; never
std::fs/std::netin request paths - Use
tokio::task::spawnfor 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>,
}
PgPoolis cheap to clone (Arc internally) — no need to wrap it manually- Pass
State<AppState>to handlers - Avoid
lazy_static!/OnceCellfor things you can put inAppState
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 implementIntoResponse - Use Axum extractors (
State,Path,Query,Json) — they're typed - Validate input with
validatorderives 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
thiserrorfor typed library errors;anyhowonly at binary boundaries
Database (sqlx)
- Use
sqlx::query!/sqlx::query_as!for compile-time-checked queries - Set
SQLX_OFFLINE=truein 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::TraceLayermiddleware for per-request spans - Never log secrets
Patterns to follow
- One
mod.rsper route group; register all routes inapp.rs - Use newtype wrappers for IDs (
UserId(Uuid)) to avoid mixups From/TryFromimpls for type conversions; not free functionsderive(Serialize, Deserialize)deliberately — separate DTOs from domain when shapes diverge
Patterns to avoid
.unwrap()/.expect()in request pathsBox<dyn Error>when you can use a typed errorasync fnreturningimpl FuturewithoutSendwhen it must cross threads- Holding a
PgPoolconnection across anawaitboundary — use a tx if you need to - Mutating shared state without synchronization —
Arc<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 withreqwest - Use
sqlx::testmacro to provision a per-test DB
Tooling
cargo runcargo testcargo clippy --all-targets --all-features -- -D warningscargo fmt --allsqlx migrate runcargo 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
AppErrorenum withIntoResponse; 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, andcargo fmt --checkbefore declaring a task done