Use when writing, reviewing, testing, or shipping Rust — ownership and the borrow checker (move/borrow/clone, Arc/RefCell, lifetimes), errors with Result/`?`/thiserror/anyhow, async on tokio, axum 0.8 services, cargo test, and sqlx + cargo-audit hardening. NOT the same service in Go (that is `go`), NOT a desktop webview shell (that is `tauri`).
Installs into .claude/skills of the current project.
Are you the author of Rust?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-rust)
---
name: rust
description: "Use when writing, reviewing, testing, or shipping Rust — ownership and the borrow checker (move/borrow/clone, Arc/RefCell, lifetimes), errors with Result/`?`/thiserror/anyhow, async on tokio, axum 0.8 services, cargo test, and sqlx + cargo-audit hardening. NOT the same service in Go (that is `go`), NOT a desktop webview shell (that is `tauri`)."
tags: [rust, tokio, axum, async, backend, service]
recommends: [go, postgresdb, secure-coding, deployment]
origin: risco
---
# Idiomatic Rust services
Write, review, test, and ship idiomatic async Rust services with the ownership model working *for*
you, not against you.
Targets **Rust 1.85+ / edition 2024** as the floor: native `async fn`
in traits (no reflexive `#[async_trait]`), **tokio 1.x** as the runtime, **axum 0.8** for the HTTP
surface (`{id}` path-capture syntax, async-trait-free extractors), **thiserror 2** for library error
enums and **anyhow** at the application edge, **sqlx** for compile-time-checked SQL, and **tracing**
for structured observability.
The thing an agent gets wrong in Rust is almost never syntax — it is *ownership*. Most "bugs" are
compile errors about moves, borrows, and `Send + Sync` across `.await`. Front-load that mental model;
the rest follows.
## Ownership & borrowing (essentials)
This is the skill's center of gravity. Three moves: **move** (transfer ownership), **borrow** (`&`/`&mut`,
no transfer), **clone** (a real copy, real cost) — and prefer them in that order, borrow first.
Take `&str`/`&[T]` in function params, return owned `String`/`Vec<T>`: borrow on the way in, own on the
way out is both the most flexible and the cheapest.
```rust
fn print_name(name: &str) { println!("{name}"); } // borrows; caller keeps ownership
let s = String::from("ada");
print_name(&s); // Good: lend a reference
println!("{s}"); // still usable
// Bad: takes by value, moves it, then the caller can't use `s` anymore.
fn consume(name: String) { /* ... */ }
consume(s);
// println!("{s}"); // error[E0382]: borrow of moved value: `s`
```
The four borrow-checker errors you will actually hit, with the fix:
```rust
// 1. "value moved here" (E0382): you used a value after moving it.
// Fix: borrow instead of move, or .clone() only if you genuinely need two owners.
let v = vec![1, 2, 3];
let first = &v[0]; // Good: borrow
// let taken = v; let _ = first; // Bad: moves v while `first` borrows it.
// 2. "cannot borrow as mutable more than once" (E0499): two &mut alive at once.
// Fix: scope the first borrow so it ends before the second begins.
let mut data = vec![1, 2, 3];
{ let a = &mut data; a.push(4); } // borrow ends here
let b = &mut data; b.push(5); // Good: non-overlapping
// 3. "cannot borrow as mutable, already borrowed as immutable" (E0502).
// Fix: don't hold a shared ref across a mutation; collect indices first, mutate after.
// 4. "does not live long enough" (E0597): a reference outlives the value it points to.
// Fix: return an owned value, or restructure so the owner outlives the borrow.
```
Shared state: pick the smallest tool that fits. Decision table —
| Need | Use | Why |
| --- | --- | --- |
| One owner, sized value | the value, or `Box<T>` | `Box` only when heap/indirection/`dyn` is required |
| Shared ownership, single thread | `Rc<T>` | cheap refcount, **not** thread-safe |
| Shared ownership, across threads/await | `Arc<T>` | atomic refcount; the default for async app state |
| Interior mutability, single thread | `RefCell<T>` | runtime borrow check; panics on violation |
| Shared mutable state, async | `Arc<Mutex<T>>` (tokio's) | but prefer a channel if it is really message passing |
| Read-heavy shared state | `Arc<RwLock<T>>` | many readers, rare writer |
Shared async state is `Arc<AppState>` injected through axum `State` — never a global `static mut`.
Lifetimes, `'static`, `Cow`, and the full smart-pointer tree -> `references/ownership.md`.
## Errors
Error modeling is owned here, in Rust terms — it is not a separate skill. The model: `Result<T, E>` +
`?`, typed enums for libraries, `anyhow` at the edge, one mapping from a domain enum to an HTTP status.
```rust
use thiserror::Error;
// Library / domain layer: a typed enum callers can match on. #[from] gives free `?` conversion.
#[derive(Debug, Error)]
pub enum UserError {
#[error("user {0} not found")]
NotFound(i64),
#[error("database error")]
Db(#[from] sqlx::Error), // any sqlx::Error becomes UserError::Db via `?`
}
```
```rust
// Application edge: anyhow when you just need context, not a match.
use anyhow::Context;
let config = std::fs::read_to_string(path)
.with_context(|| format!("reading config at {path}"))?; // adds a human breadcrumb
```
**The 3-layer flow (twin of go's).** Repository returns the typed domain error; service propagates with
`?`; the handler maps the enum to a status *once*, via `IntoResponse` — pattern-match the variant, never
string-match the message, and log only the unexpected one (no internal leak to the client).
```rust
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
impl IntoResponse for UserError {
fn into_response(self) -> Response {
let status = match self {
UserError::NotFound(_) => StatusCode::NOT_FOUND, // 404
UserError::Db(ref e) => { // 500
tracing::error!(error = %e, "unexpected db error"); // log here, not to the client
StatusCode::INTERNAL_SERVER_ERROR
}
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}
```
Full repo->service->handler skeleton -> `references/axum-service.md`.
## Async (tokio, essentials)
`#[tokio::main]` boots the multi-thread runtime; futures do nothing until `.await`. Bound your fan-out:
```rust
use tokio::task::JoinSet;
let mut set = JoinSet::new();
for id in ids { // Good: a JoinSet you can drain and cap
set.spawn(async move { fetch(id).await });
}
let mut out = Vec::new();
while let Some(res) = set.join_next().await {
out.push(res??); // join error, then task error
}
```
The two pitfalls that bite agents, with the fix:
```rust
// Bad: std Mutex guard held across .await -> "future cannot be sent between threads safely".
let guard = state.lock().unwrap();
do_io().await; // guard is still alive here -> not Send
guard.update();
// Good: drop the lock before awaiting, or use tokio::sync::Mutex if the lock must span the await.
{
let mut g = state.lock().unwrap();
g.update();
} // guard dropped here
do_io().await; // nothing non-Send is held across the await
```
```rust
// Bad: a CPU-bound parse on the async worker thread starves every other task.
let parsed = heavy_parse(&blob); // blocks the executor
// Good: move blocking/CPU work off the runtime.
let parsed = tokio::task::spawn_blocking(move || heavy_parse(&blob)).await?;
```
`select!` races futures (handle a cancellation token in one arm); `tokio::sync::mpsc` for message
passing — prefer a channel over `Arc<Mutex<T>>` when the data flows one way. Cancellation, a
bounded-concurrency + jittered-retry helper (ctx-aware, never retries a 4xx), and the full `Send + Sync`
rules -> `references/async-tokio.md`.
## Service (axum, essentials)
axum 0.8: `{id}` capture in the path, `Path`/`State`/`Json` extractors, your error enum as the return:
```rust
use axum::{extract::{Path, State}, routing::get, Router, Json};
use std::sync::Arc;
async fn get_user(
State(app): State<Arc<AppState>>, // shared state, not a global
Path(id): Path<i64>, // {id} parsed and typed
) -> Result<Json<User>, UserError> { // UserError: IntoResponse maps it
let user = app.users.find(id).await?; // `?` propagates the typed error
Ok(Json(user))
}
let app = Router::new()
.route("/users/{id}", get(get_user)) // 0.8 syntax: {id}, not :id
.with_state(state);
```
Validate at the boundary and parse into a typed domain model — "parse, don't validate" makes illegal
states unrepresentable, so the handler body never re-checks. Full skeleton — tower middleware
(`TraceLayer`, timeout, request-id), graceful shutdown via
`axum::serve(...).with_graceful_shutdown(...)`, and JSON helpers -> `references/axum-service.md`.
## Project layout
Keep the binary thin; put logic in the library so tests and integration tests can reach it.
```text
my-service/
Cargo.toml # [dependencies], [profile.release], optional [workspace]
src/
main.rs # entrypoint: parse config, build state, axum::serve — wiring only
lib.rs # pub mod error; pub mod app; pub mod users; — the testable surface
error.rs # the thiserror enum + IntoResponse
users/
mod.rs # handlers + the domain model
repo.rs # sqlx queries
tests/
users_api.rs # integration tests that spin up the Router
```
A larger system becomes a Cargo **workspace** (`[workspace] members = [...]`) with one crate per bounded
context. Gate optional deps behind `[features]`. The `lib.rs` carries `#![forbid(unsafe_code)]`.
## Testing (essentials)
`#[test]` for sync, `#[tokio::test]` for async; integration tests under `tests/` exercise the real
`Router`; doctests keep examples honest.
```rust
#[tokio::test]
async fn get_user_404_when_missing() {
let app = build_router(test_state()); // the same Router main builds
let res = app
.oneshot(Request::get("/users/999").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
```
Use `cargo nextest run` for faster, cleaner parallel runs; `cargo test --doc` for doctests. Trait-based
fakes (a `UserRepo` trait the handler depends on, a fake impl in tests) keep the DB out of unit tests.
Integration matrices, `insta` snapshots, and the full `tests/` HTTP setup -> `references/testing.md`.
## Security (embedded)
Parametrize SQL, forbid unsafe, audit dependencies, read secrets from the environment:
```rust
// Good: bound parameters; sqlx checks the query at compile time against the DB schema.
sqlx::query_as!(User, "SELECT id, name FROM users WHERE id = $1", id).fetch_one(&pool).await?;
// Bad: format! into SQL is injection, full stop.
// sqlx::query(&format!("SELECT * FROM users WHERE id = {id}")).fetch_one(&pool).await?;
```
`#![forbid(unsafe_code)]` at the crate root; run `cargo audit` (RustSec advisories) and `cargo deny` (license
+ ban + advisory policy) in CI; never `.unwrap()` on untrusted input — a malicious request becomes a
panic. Read secrets from env or a secret manager, never hardcode or log them. Deeper authz / threat
modeling -> [`secure-coding`](../secure-coding/SKILL.md). Pure SQL schema/index/plan tuning ->
[`postgresdb`](../postgresdb/SKILL.md); this skill covers only the Rust-side sqlx query.
## Production
Structured logs and a lean release binary:
```rust
// JSON tracing subscriber, level from RUST_LOG; do this once in main before serving.
tracing_subscriber::fmt().json().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).init();
```
```toml
[profile.release]
lto = true # link-time optimization: smaller, faster binary
codegen-units = 1 # better optimization at the cost of compile time
panic = "abort" # no unwinding in prod; smaller binary, fail fast
strip = true # strip symbols
```
Expose `/healthz` (static 200 liveness) and `/readyz` (pings the DB pool, 503 on failure). Docker:
multi-stage build, `cargo build --release`, copy the binary onto a distroless/slim base. Full
Containerfile + CI -> [`deployment`](../deployment/SKILL.md).
## Anti-patterns
| Anti-pattern | Reality / Do instead |
| --- | --- |
| `.clone()` to make the borrow checker happy | It hides the real ownership question; borrow, or restructure who owns what. |
| `.unwrap()` / `.expect()` off the test path | A panic on the request path is a 500 or a crashed worker; use `?` + a typed error. |
| Matching an error by its message string | Messages are prose and they change; match the enum variant. |
| `Box<dyn Error>` everywhere because it is simpler | Nothing can branch on the failure; use a `thiserror` enum the caller can match. |
| `#[async_trait]` on every async trait | Edition 2024 has native async fn in traits; drop the macro for most cases. |
| `block_on` inside an async fn | Nesting a runtime panics or deadlocks; restructure to `.await`. |
| A bare `tokio::spawn` per loop iteration | Unbounded fan-out exhausts the runtime; bound it with `JoinSet`/semaphore. |
| `Arc<Mutex<T>>` for everything shared | If data flows one way it is a channel; reach for `mpsc` first. |
| `unsafe` to get past the borrow checker | `unsafe` turns a compile error into UB; the checker was right — restructure. |
| Skipping clippy as "just style" | clippy catches correctness (`.unwrap()` on `Option`, await-holds-lock); gate on `-D warnings`. |
## Gates & commands
| Task | Command |
| --- | --- |
| Format check | `cargo fmt --all -- --check` |
| Lint (gate) | `cargo clippy --all-targets -- -D warnings` |
| Test | `cargo test` / `cargo nextest run` |
| Doctests | `cargo test --doc` |
| Audit deps | `cargo audit` / `cargo deny check` |
| Local gate | `./scripts/verify.sh` (run in your crate root) |
Format and lint are build gates, not suggestions.
## Project grounding (02-DOCS)
In a project with a `02-DOCS/` layer (the [`harness`](../harness/SKILL.md) wiki), the service decisions
live in `02-DOCS/wiki/stack/rust.md`, indexed from `02-DOCS/wiki/index.md`. Read it first and stay
consistent; if it is missing or stale, write the project's real choices there — crate/workspace layout,
runtime (tokio), HTTP framework (axum 0.8), error strategy (thiserror enum + `IntoResponse` mapping), DB
layer (sqlx + pool), tracing and concurrency defaults — bump its `Updated` date, and index it. No
`02-DOCS/`? Skip silently. Conventions are *recorded, not gated* — never block the task on this.
[`go`](../go/SKILL.md) is the structural twin: same write/review/test/ship service shape, GC +
goroutines + multi-return errors instead of ownership + futures + `Result`. A desktop shell around a
webview is [`tauri`](../tauri/SKILL.md), not this.