Backend development: Rust

Sefism.

Chapter 01

Rust essentials for the backend

The parts of Rust that shape every service: ownership, enums and Result, traits, Cargo, async on Tokio, shared state, and the Axum and SQLx crates most APIs start from.

Ownership and borrowing

Also called borrow checker, ownership, references, move semantics, lifetimes

What
Every value has one owner and is dropped when the owner goes out of scope. You lend it with references: any number of shared &T borrows, or one mutable &mut T, never both at once. The compiler checks these rules before the program runs.
Why
It gives memory safety and freedom from data races without a garbage collector, so a Rust service has predictable latency and low memory use.
Rust
let name = String::from("Ali");
let moved = name;          // ownership moves
// println!("{name}");     // error: value was moved
let len = length(&moved);  // a borrow; moved stays usable

fn length(s: &str) -> usize {
    s.len()
}

Watch out

Sprinkling .clone() everywhere to quiet the borrow checker instead of rethinking who owns the data.

Structs, enums and pattern matching

Also called enum, match, struct, Option

What
Structs group named fields. Enums list the shapes a value can take, and each variant can carry its own data. match handles every variant, and the compiler rejects a match that forgets one.
Why
Model states such as an order status as an enum and the compiler points at every place to update when you add a state. Option<T> replaces null.
Rust
enum OrderStatus {
    Pending,
    Paid { at: DateTime<Utc> },
    Cancelled(String),
}
fn label(s: &OrderStatus) -> String {
    match s {
        OrderStatus::Pending => "pending".into(),
        OrderStatus::Paid { at } => format!("paid {at}"),
        OrderStatus::Cancelled(r) => format!("cancelled: {r}"),
    }
}

Watch out

Ending a match with a catch-all _ arm, which hides the variants you add later.

Result, Option and the ? operator

Also called Result, ? operator, thiserror, anyhow, unwrap

What
A function that can fail returns Result<T, E>, and a value that may be missing is an Option<T>. The ? operator hands an error back to the caller early. thiserror defines typed errors, and anyhow adds context in application code.
Why
Failure is part of the type, so you cannot forget it, and each error variant can map to one HTTP status in one place.
Rust
#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("not found")]
    NotFound,
    #[error(transparent)]
    Db(#[from] sqlx::Error),
}

async fn load(db: &PgPool, id: i64) -> Result<User, AppError> {
    let user = users::find(db, id).await?; // Option<User>
    user.ok_or(AppError::NotFound)
}

Watch out

Calling .unwrap() in request handlers, which turns bad input into a panic instead of a clean error response.

Traits and generics

Also called trait, impl Trait, trait bounds, generics

What
A trait lists methods a type promises to have, implemented with impl Trait for Type. A generic function with a trait bound, such as fn welcome(n: &impl Notifier), accepts any type that implements the trait.
Why
Axum and SQLx are built on traits such as IntoResponse and FromRow, and a small trait lets a test swap a real email sender or database for a fake.
Rust
trait Notifier {
    fn send(&self, to: &str, msg: &str) -> Result<(), Error>;
}
struct FakeNotifier;
impl Notifier for FakeNotifier {
    fn send(&self, _: &str, _: &str) -> Result<(), Error> {
        Ok(())
    }
}
fn welcome(n: &impl Notifier, to: &str) -> Result<(), Error> {
    n.send(to, "Welcome to the shop")
}

Watch out

Adding traits and generics everywhere up front; start concrete and add a trait when a second implementation, often a test fake, needs one.

Cargo, crates and features

Also called Cargo, Cargo.toml, Cargo.lock, crates.io, clippy

What
Cargo builds, tests and runs Rust code. Cargo.toml lists dependencies from crates.io and the optional features you switch on for each, and Cargo.lock pins every exact version. A crate is one library or program.
Why
One tool covers the whole loop (build, test, format, lint, docs), and the lockfile makes CI and production builds reproducible.
Shell
cargo new shop-api && cd shop-api
cargo add axum serde --features serde/derive
cargo add tokio --features full
cargo add sqlx --features runtime-tokio,postgres,macros
cargo fmt && cargo clippy -- -D warnings
cargo test
cargo build --release

Watch out

Not committing Cargo.lock for a service, or switching on every feature of a crate, which slows builds and grows the binary.

async/await on Tokio

Also called Tokio, async, await, futures, spawn_blocking, tokio::join

What
An async function returns a future, which does nothing until it is awaited. Tokio is the runtime that drives futures on a small pool of threads and parks a task while it waits on I/O. tokio::join! runs independent calls concurrently.
Why
One process can hold many thousands of open connections cheaply, and Axum, SQLx and most Rust network crates are built on it.
Rust
// two independent queries, run concurrently
let (user, orders) = tokio::join!(
    users::find(&db, id),
    orders::for_user(&db, id),
);

// CPU-heavy work goes to the blocking thread pool
let hash = tokio::task::spawn_blocking(move || {
    hash_password(&password)
})
.await??;

Watch out

Blocking inside async code, such as std::thread::sleep, password hashing or a large sync file read, which stalls every task on that worker thread.

Shared state: Arc, Mutex, Send and Sync

Also called Arc, Mutex, Send, Sync, AppState

What
Arc<T> shares one value between tasks by counting references, and Mutex<T> or RwLock<T> lets them change it one at a time. Send and Sync are marker traits the compiler uses to reject sharing that could race.
Why
Handlers run on many threads at once, so app state such as a pool, config or a small in-memory cache must be safe to share, and Rust proves it before the code runs.
Rust
#[derive(Clone)]
struct AppState {
    db: PgPool, // a pool is already cheap to clone
    hits: Arc<Mutex<HashMap<String, u64>>>,
}

async fn count(State(s): State<AppState>) -> String {
    let mut hits = s.hits.lock().unwrap();
    let n = hits.entry("home".into()).or_default();
    *n += 1;
    n.to_string()
}

Watch out

Holding a lock across an .await: with std::sync::Mutex the handler stops compiling, and with an async mutex it quietly queues requests.

Axum routers, extractors and IntoResponse

Also called Axum, extractor, Router, IntoResponse, serde

What
An Axum handler is an async function whose arguments are extractors: Path, Query, Json and State pull typed values out of the request. Anything that implements IntoResponse can be returned, and serde turns structs into JSON.
Why
Parsing happens before your code runs, so a malformed path or body is rejected with a 4xx and the handler only ever sees typed data.
Rust
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let status = match self {
            Self::NotFound => StatusCode::NOT_FOUND,
            Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
        };
        let title = status.canonical_reason();
        (status, Json(json!({ "title": title })))
            .into_response()
    }
}

Watch out

Putting the Json extractor before other arguments: it consumes the body, so it has to be the last one.

SQLx: checked queries and a pool

Also called SQLx, query_as!, PgPool, cargo sqlx prepare, Diesel

What
SQLx runs plain SQL asynchronously. PgPool is a connection pool you create once and share. The query! and query_as! macros check each query against a real database at compile time, or against metadata saved by cargo sqlx prepare.
Why
A misspelt column or a wrong type fails the build instead of a request in production, and the SQL stays in plain sight. Diesel is the main alternative, a query builder and ORM.
Rust
let pool = PgPoolOptions::new()
    .max_connections(10)
    .connect(&std::env::var("DATABASE_URL")?)
    .await?;
sqlx::migrate!().run(&pool).await?; // files in ./migrations

let user = sqlx::query_as!(User,
    "SELECT id, email FROM users WHERE id = $1", id)
    .fetch_optional(&pool)
    .await?;

Watch out

Not committing the .sqlx folder that cargo sqlx prepare writes, so CI without a database cannot compile the query macros.

01 · Rust essentials for the backend1 / 10