rust-axum-service
Use when building or extending a Rust service - axum on tokio (or the repository's actix-web / rocket), typed errors with IntoResponse, serde at the boundary, sqlx queries and migrations, never blocki
Files1
View on GitHubSKILL.mdcatalog/agents/backend-developer/skills/rust-axum-service/SKILL.md
GitHubRust Service (axum on tokio)
Overview
Rust moves a class of bugs to compile time, and leaves two to you: panics on a request path and blocking the async runtime. Most of the rules below are about those two.
Core principle: the repository's framework, error crates, database crate and workspace layout win. Handlers stay thin; the domain is plain Rust that compiles without the web framework.
Layers
| Layer | Responsibility | axum surface |
|---|---|---|
| Router / handler | Extract and validate input, call one service, map the result | Router::new().route(...), extractors (State, Path, Json), impl IntoResponse |
| Service | Use-case orchestration and business rules | a struct holding its ports, async fn methods |
| Domain | Types and invariants: newtypes whose constructors validate | plain structs and enums, thiserror error enum |
| Adapter | Database, outbound HTTP, queues | a type implementing the port trait (sqlx, reqwest) |
In a workspace the domain is often its own crate; a crate that does not depend on axum cannot leak it.
Patterns
- Toolchain:
rust-toolchain.tomlwhen present; edition fromCargo.toml(2024 for a new crate). CommitCargo.lockfor a binary. Add crates withcargo add, the version from crates.io, not memory. - No panics on a request path: no
unwrap(),expect(), indexing that can go out of bounds, orascasts that truncate on input-derived values. Use?with a typed error.expectis for startup invariants with a message that says why it cannot fail. - Errors: a domain error enum with
thiserror; an application error that implementsIntoResponseand maps each variant to 400/404/409 in the repository's problem shape, logging the source of a 500 and never returning it.anyhowbelongs inmainand scripts, not in a library or a handler's signature. - Parse, don't validate: request DTOs
#[derive(Deserialize)](with#[serde(deny_unknown_fields)]where the contract is strict), converted into domain newtypes (TaskTitle::try_from(String)) whose constructors enforce the invariant. A malformed JSON body is already a 4xx throughJson's rejection — keep that rejection in the same error shape. - State: one
AppState(Clone, cheap:Arcs and pools inside) passed with.with_state(state)and taken byState<AppState>; no global mutable statics. - Never block the runtime: blocking I/O or CPU-heavy work goes to
tokio::task::spawn_blocking; nostd::thread::sleepin async code (tokio::time::sleep); never hold astd::sync::Mutexguard across.await— restructure, or usetokio::sync::Mutexwhen the lock must span an await. - Timeouts and limits:
tower_httplayers —TimeoutLayer,RequestBodyLimitLayer,TraceLayer,CorsLayerconfigured, notpermissive()in production. Outboundreqwestclients built once with a timeout and shared. - Database: sqlx with bound parameters (
query!/query_as!checked at compile time; keep the offline.sqlxdata committed withcargo sqlx preparewhen the repository uses offline mode) or the repository's diesel / sea-orm. Neverformat!a SQL string with input. Migrations withsqlx migrate add <name>(reversible when the repository's are) applied bysqlx::migrate!()or the deploy step; postgres-migrations covers safe DDL. - Logging:
tracingwith structured fields and#[tracing::instrument(skip(state, body))]on handlers and service methods — skip anything secret or large; JSON output fromtracing-subscriberwhere the repository logs JSON. - Graceful shutdown:
axum::serve(listener, app).with_graceful_shutdown(signal)on SIGTERM / Ctrl-C. unsafe: none in a feature task. If a dependency forces it, say so in a task comment; the security agent reviews everyunsafeblock.
Worked Example
POST /tasks:
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateTask { title: String }
pub async fn create_task(
State(state): State<AppState>,
user: AuthUser,
Json(body): Json<CreateTask>,
) -> Result<(StatusCode, Json<TaskResponse>), AppError> {
let title = TaskTitle::try_from(body.title)?; // 400 through AppError on a bad title
let task = state.tasks.create(user.id, title).await?; // the service owns the rule set
Ok((StatusCode::CREATED, Json(TaskResponse::from(task))))
}
TaskTitle::try_from trims and rejects empty or over-200-character titles, so no Task with an invalid title can exist anywhere in the program.
Verify
The repository's commands first (list_component_checks, the Makefile, CI). Otherwise: cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings, cargo test. For the runtime check build once — cargo build --release (or the workspace's -p <api>) — and start target/release/<api> in the background with its port and DATABASE_URL set, rather than cargo run, whose child a terminal timeout can orphan.
Common Mistakes
.unwrap()on a parse of user input — one bad request takes the connection down with a panic.std::fsor a synchronous client inside anasync fnhandler.- Cloning a large value per request where an
Arcwould do — or the reverse, wrapping everything inArc<Mutex<_>>. #[allow(clippy::...)]added to get the build green.
Red Flags
format!("SELECT ... {}", input)anywhere.- A
MutexGuardalive across an.await. unsafein a diff with no comment explaining the invariant it relies on.tokio::spawnwith theJoinHandledropped and no error handling inside the task.