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
&Tborrows, 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.
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.
matchhandles 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.
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 anOption<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.
#[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 asfn welcome(n: &impl Notifier), accepts any type that implements the trait. - Why
- Axum and SQLx are built on traits such as
IntoResponseandFromRow, and a small trait lets a test swap a real email sender or database for a fake.
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.tomllists dependencies from crates.io and the optional features you switch on for each, andCargo.lockpins 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.
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 --releaseWatch 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.
// 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.
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,JsonandStatepull typed values out of the request. Anything that implementsIntoResponsecan 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.
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.
PgPoolis a connection pool you create once and share. Thequery!andquery_as!macros check each query against a real database at compile time, or against metadata saved bycargo 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.
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.