Welcome
CodeCora Rust Learning Hub — the internal course for engineers learning Rust the way we actually use it: production-flavored, project-first.
Why this exists
Everything we ship at CodeCora is Rust-shaped: Uteke
(search engine), Sinau LMS (Axum + sqlx), nginjen (Axum scraper), vecq, gatehouse, Bifrost
tooling — all Axum services, sqlx-backed, tokio-async. This course teaches exactly that
stack, in the order the stack demands it.
How this course works
Every module is a project you build and keep, not a tour of language features. Foundational theory shows up just in time, inside the project it serves.
Each chapter follows the same shape:
- Goal — one observable capability (“you can X”).
- Concepts — the minimum theory, concept-first.
- Lab walkthrough — real, verified code from the
labs/directory. - Exercises — concrete tasks with a checkable “done” criterion.
Every code sample in this book compiles and passes cargo test, cargo clippy -D warnings, and cargo fmt on Rust 1.96 (the toolchain we standardize on).
The labs live in labs/ — clone, run, break, fix.
Curriculum at a glance
| Module | Project you keep | Rust you internalize |
|---|---|---|
| Gate | Tested CLI binary | toolchain, cargo, panic vs error |
| M1 | Log parser CLI | ownership, borrowing, structs, enums |
| M2 | Error-typed parser lib | Result, ?, Display, From, error codes |
| M3 | Search/filter engine | traits, generics, iterators, tests |
| M4 | Concurrent fetcher | async/await, tokio, select, timeouts |
| M5 | REST API (Axum) | routers, extractors, middleware, state |
| M6 | Persistence layer | sqlx, migrations, SQLite/Postgres |
| M7 | CI-hardened service | clippy, fmt, tests, GH Actions gates |
| M8 + Capstone | Mini search service | workspace crates, end-to-end assembly |
Ground rules
- Tested means live. Nothing is “done” because it compiles — it is done when the test suite is green and the binary behaves.
- No unsafe, no
unwrap()in production paths. Panics are for programmer bugs; user input problems are values (Result), never crashes. - One error enum per crate. The M2 pattern is the house style — you will see it again in every Axum service we run.
- English artifacts. Code, comments, commits — all English (house standard).
This site is private — internal use only. Do not share links outside the team.
Setup & Toolchain
Goal: you can verify a working Rust toolchain and explain what each core cargo
command does — in under 30 minutes.
Install
Use rustup (the official toolchain manager). On Linux/macOS:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
Verify:
rustc --version # compiler — we standardize on 1.96.x
cargo --version # build tool + package manager
rustup update # run occasionally; Rust ships every 6 weeks
Why rustup, not apt? Distro packages are always stale. Rust’s release cadence is 6 weeks; rustup also manages multiple toolchains (stable / nightly / specific MSRV) and cross-compile targets. Everything CodeCora runs on ARM (aarch64) — rustup handles that transparently.
Editor
Any editor with rust-analyzer works. Minimum setup:
- rust-analyzer (LSP): completions, inline types, go-to-definition through macros
rustfmton save (the Format check in our CI iscargo fmt --all -- --check)- clippy as the lint source (CI runs
cargo clippy --all-targets -- -D warnings)
The cargo commands you will use daily
| Command | What it does | When |
|---|---|---|
cargo new <name> | scaffold a binary crate | starting any project |
cargo run | build + execute | inner loop |
cargo build --release | optimized build | perf / shipping |
cargo test | run all tests | before every commit |
cargo check | type-check without codegen | fastest feedback |
cargo clippy -- -D warnings | lint; warnings are errors | before every commit |
cargo fmt | auto-format (rustfmt) | before every commit |
cargo add <crate> | add a dependency | when a crate earns its place |
The house loop (burn this in — it is also our CI gate):
cargo fmt && cargo clippy --all-targets -- -D warnings && cargo test
Crates, packages, modules — 60 seconds
- Package = a
Cargo.toml(whatcargo newcreates). Builds one or more crates. - Crate = one compilation unit: a binary (
src/main.rs) or a library (src/lib.rs). - Module = a namespace inside a crate (
mod parser;→src/parser.rs). - Dependency = someone else’s crate, from crates.io,
pinned in
Cargo.toml+ locked inCargo.lock(commit the lockfile for binaries).
Lab: clone and run the gate lab
git clone https://github.com/codecoradev/rust-lms
cd rust-lms/labs/gate-hello
cargo test # expect: 2 passed
cargo run # Hello from Rust 0.1.0 — toolchain works!
cargo run -- --add 40 2 # 40 + 2 = 42
Exercises
- Verify — run the house loop above in
labs/gate-hello. All three must pass. - Break it — change
--addto printa - binstead. Re-run the loop. - Explain — out loud, in one sentence each: what
cargo checkdoes thatcargo buildskips, and why that makes it faster.
Done when: the house loop is green after your change, and you ran the binary at least once.
Your First Rust Program
Goal: you can read the gate lab line by line and explain every construct in it — including why one deliberate line panics.
The whole program
labs/gate-hello/src/main.rs (short version):
use std::env;
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("--add") => {
let a: i64 = args.get(1).expect("usage: gate-hello --add <A> <B>")
.parse().expect("first operand must be an integer");
let b: i64 = args.get(2).expect("usage: gate-hello --add <A> <B>")
.parse().expect("second operand must be an integer");
println!("{a} + {b} = {}", a + b);
}
Some("--crash") => {
let missing: Option<i64> = env::var("DELIBERATELY_MISSING")
.ok()
.and_then(|s| s.parse().ok());
println!("{}", missing.expect("set DELIBERATELY_MISSING=<number> to avoid this panic"));
}
_ => {
println!("Hello from Rust {} — toolchain works!", env!("CARGO_PKG_VERSION"));
}
}
}
What each piece teaches
| Line | Concept | Notes |
|---|---|---|
use std::env; | imports | bring the env module into scope |
env::args().skip(1).collect() | iterators | args() yields Strings; skip(1) drops the binary name |
args.first().map(String::as_str) | Option | empty args → None, no out-of-bounds possible |
match ... { Some("--add") => ..., _ => ... } | match | exhaustive by design — forget a case and it will not compile |
let a: i64 = ... .parse().expect(...) | turbofish-free inference | parse::<i64>() via the type annotation; parse returns Result |
.expect("...") | fail fast | fine in labs/CLIs; never in library or service code — that is M2’s topic |
env!("CARGO_PKG_VERSION") | compile-time env | baked in by cargo from Cargo.toml |
{a} + {b} in the string | inline formatting | format captures, stable since 1.58 |
Panic vs error — the distinction that shapes everything
Run the two failure modes side by side:
cargo run -- --crash # panics: process dies, rc=101
DELIBERATELY_MISSING=7 cargo run -- --crash # value existed: rc=0
- A panic is for programmer bugs — impossible states, broken invariants. Fail fast, loud, with a message.
- An error value (
Result) is for expected failure — bad input, missing file, unreachable network. The type system forces the caller to handle it.
Why is --crash a panic then? Because it simulates a broken invariant for teaching.
In real code, a CLI reading user input should return ExitCode errors (M1) and a
library should return Result (M2). Our Axum services never unwrap() user input —
that discipline starts here.
Tests live next to the code
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
#[test]
fn binary_has_a_version() {
assert!(!env!("CARGO_PKG_VERSION").is_empty());
}
}
}
#[cfg(test)]— compiled only forcargo test, zero cost in release builds.#[test]— the runner discovers and runs it; a panic inside = failure.assert!/assert_eq!— the workhorses. No separate test framework needed.
Exercises
- Trace it — add
dbg!(&args);as the first line ofmain, run all three modes, and write down whatdbg!prints (file, line, expression, value). - Extend — add a
--echobranch that prints all remaining args joined by spaces. - Feel the compiler — remove the
_ =>arm. Try to compile. Read the error. Put it back. That error ismatchexhaustiveness — you will learn to love it.
Done when: all three modes work, the removed-_-arm compile error is something you
can explain in one sentence, and the house loop is green.
Ownership, Borrowing, Slices
Goal: you can predict which log-parser designs the borrow checker accepts — and explain why — without compiling first.
The three rules
- Each value has exactly one owner; when the owner goes out of scope, the value is dropped (freed). No GC, no manual free.
- At any time you may have either one mutable reference (
&mut T) or any number of shared references (&T) — never both. - References must never outlive the data they point at (no dangling).
That is the entire model. Everything that feels restrictive in week one becomes a concurrency-safety guarantee by month two: data races are compile errors.
Move, clone, borrow
#![allow(unused)]
fn main() {
let s1 = String::from("boot");
let s2 = s1; // MOVE: s1 is now invalid (String owns heap memory)
// println!("{s1}"); // ← compile error: borrow of moved value
let s3 = s1.clone(); // explicit deep copy — visibility over hidden cost
let len = strlen(&s3); // BORROW: s3 still usable afterwards
fn strlen(s: &str) -> usize { s.len() }
}
i64, bool, char and friends are Copy types — assignment copies, no move semantics.
Strings and Vecs move. That asymmetry is the #1 week-one confusion; name it early.
Slices: borrow a view, not a copy
#![allow(unused)]
fn main() {
let line = String::from("2026-09-19T09:30:00Z INFO boot complete");
let ts: &str = &line[..20]; // borrowed view into line's buffer
let msg: &str = line.split_once(' ').unwrap().1; // same buffer, different window
}
A slice is a pointer + length — no allocation. The parser lab leans on this: split a
line into &str views, and only to_string() the pieces that must outlive the input.
The lab: what moves where
labs/m1-logpar — parse 2026-09-19T09:30:00Z INFO boot complete lines and count
levels. The ownership decisions, annotated:
#![allow(unused)]
fn main() {
struct LogEntry {
ts: String, // OWNED — entries outlive the parse() borrow of the input
level: Level, // COPY — a tiny enum, no heap
msg: String, // OWNED
}
fn parse_line(line: &str) -> Option<LogEntry> { // borrows the line...
let mut parts = line.splitn(3, ' ');
let ts = parts.next()?; // ...slices are views into it
let msg = parts.next()?.trim().to_string(); // ...only this outlives the fn
Some(LogEntry { ts: ts.to_string(), level, msg })
}
fn parse(input: &str) -> Vec<LogEntry> { // one borrow of the whole doc
input.lines().filter_map(parse_line).collect()
}
}
Design logic:
parse_line(&str) -> Option— borrow in,Nonefor garbage (a log must skip junk, not crash)..to_string()exactly where data must escape the borrow.parse(&str) -> Vec<LogEntry>— the inputStringinmainis never moved; the function borrows it, then hands back fully-owned entries.counts_by_level(&[LogEntry])— borrows the vec to count;mainkeeps ownership and prints error lines afterwards. Two borrows, zero copies.
Run it and watch the exit criterion:
cargo run -p m1-logpar sample.log
# INFO 2 / WARN 1 / ERROR 1 / total 4, then each ERROR line, exit code 1
ExitCode instead of a bare exit() keeps main honest and testable — the same
reason Axum handlers return values instead of writing to the response directly.
Exercise
- Change
parse_lineto takeline: String(owned). Doesparsestill compile? Why doesinput.lines()force you back to borrows? - Make
counts_by_leveltakeVec<LogEntry>by value. What breaks inmain, and why is the&[LogEntry]version strictly better? - Add a
Debug-print of one entry ({:?}). Which derive made that possible?
Done when: cargo test -p m1-logpar is green (4 tests) and you can say, for each
struct field, whether it is owned or borrowed — and why.
Structs, Enums, and match
Goal: you can model a small domain (log levels, entries, summaries) with structs and
enums, and dispatch with match — no inheritance, no exceptions.
Structs: data with named fields
#![allow(unused)]
fn main() {
struct LogEntry {
ts: String,
level: Level,
msg: String,
}
}
- Construct:
LogEntry { ts, level, msg }(field-init shorthand when names match — clippy enforces it). - Methods live in a separate
impl LogEntry { ... }block — data and behavior are textually apart, unlike OO class bodies. #[derive(Debug, PartialEq, Eq)]auto-implements printing and comparison — most structs in our services derive at leastDebug.
Enums: closed sets of variants
Rust enums are sum types — each variant can carry its own data:
#![allow(unused)]
fn main() {
enum Level { Info, Warn, Error } // simple, C-like — but type-checked
enum LineOutcome { // variants carry different payloads
Ok(LogEntry),
Skipped { reason: &'static str },
Empty,
}
}
That second shape is exactly how std defines Option<T> and Result<T, E> — and how
our services define error enums (M2). One type, finite variants, compiler-checked
handling everywhere.
match: the only dispatch you need
#![allow(unused)]
fn main() {
impl Level {
fn as_str(&self) -> &'static str {
match self {
Level::Info => "INFO",
Level::Warn => "WARN",
Level::Error => "ERROR",
}
}
}
}
- Exhaustive: add a
Debugvariant later and everymatchonLevelfails to compile until handled. Refactoring with a net. - Expressions:
matchproduces a value (noreturnneeded per arm). - Patterns do the unwrapping:
Some(x) =>,Ok(v) =>,Err(e) =>— destructuring built into the language.
Counting with an array instead of a HashMap — allocation-free and total:
#![allow(unused)]
fn main() {
fn counts_by_level(entries: &[LogEntry]) -> [usize; 3] {
let mut counts = [0usize; 3];
for e in entries {
match e.level {
Level::Info => counts[0] += 1,
Level::Warn => counts[1] += 1,
Level::Error => counts[2] += 1,
}
}
counts
}
}
(For large or open-ended level sets you would reach for HashMap<Level, usize> —
the lab keeps it deliberately small.)
Exercise
- Add a
summary_text(&[LogEntry]) -> Stringfunction building"2 info, 1 warn, 1 error"— with a unit test. - Introduce
enum Priority { Low, High }and addfn priority(&Level) -> Priority(errors are High). Use it to filter before printing. - Replace
counts_by_level’smatchwith an index lookup (match e.level { l => counts[l as usize] += 1 }after adding#[repr(usize)]-style discriminants). Which version reads better? Which survives adding a variant?
Done when: tests green for summary_text, and you can articulate why exhaustive
match beats a default arm for domain enums.
Result, ?, and Error Enums
Goal: you can build the error architecture every CodeCora service uses — one error
enum per crate, Display for humans, From for ? interop, and a stable mapping to
outward-facing codes.
Result<T, E> — errors as values
#![allow(unused)]
fn main() {
enum Result<T, E> { Ok(T), Err(E) }
}
No exceptions. Failure is a value in the return type, so a function that can fail
announces it in its signature, and the compiler refuses to let you ignore the Err
side. That is why Rust services fail gracefully: the handling path is typed, not
forgotten.
The ? operator
? means: if this is Err, return the error from the current function; if Ok,
unwrap the value. It composes across layers — with one condition: the error types
must connect via From.
#![allow(unused)]
fn main() {
fn read_config(path: &str) -> Result<String, ParseError> {
let text = std::fs::read_to_string(path)?; // io::Error -> ParseError via From
Ok(text)
}
}
The house pattern: one error enum per crate
This is the exact shape of AppError in our Axum services (Sinau, nginjen) —
learned on a tiny parser so nothing distracts:
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq)]
enum ParseError {
Empty,
MissingField(&'static str),
BadLevel(String),
}
impl fmt::Display for ParseError { // human-facing message
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::Empty => write!(f, "input is empty"),
ParseError::MissingField(name) => write!(f, "missing field: {name}"),
ParseError::BadLevel(tok) => write!(f, "unknown level `{tok}` (want INFO|WARN|ERROR)"),
}
}
}
impl std::error::Error for ParseError {} // interop with the ecosystem
impl From<std::io::Error> for ParseError { // makes `?` work across the IO boundary
fn from(e: std::io::Error) -> Self {
ParseError::MissingField(match e.kind() {
std::io::ErrorKind::NotFound => "file",
_ => "readable input",
})
}
}
}
Three traits, three jobs:
| Trait | Job | Who consumes it |
|---|---|---|
Debug | developer view ({:?}) | logs, unwrap, test failures |
Display | human message ({}) | CLI output, error pages |
From<E> | conversion for ? | every caller crossing a layer |
Status codes are a rendering of errors
The mistake to un-learn: sprinkling HTTP codes through business logic. The error type stays transport-agnostic; the boundary renders it:
#![allow(unused)]
fn main() {
impl ParseError {
fn to_status(&self) -> (&'static str, u16) {
match self {
ParseError::Empty => ("422 Unprocessable Entity", 422),
ParseError::MissingField(_) => ("400 Bad Request", 400),
ParseError::BadLevel(_) => ("400 Bad Request", 400),
}
}
}
}
In an Axum service this method becomes impl IntoResponse for AppError and returns
StatusCode — same architecture, bigger stage (M5).
Fail well: the lab’s CLI behavior
$ m2-errlib mixed.log
ok [INFO] all good
skip 400 Bad Request: unknown level `TRACE` (want INFO|WARN|ERROR)
parsed 1 line(s), skipped 1
Note what the skip line does: names the category (400), quotes the offending token, states the accepted values. Errors are UX. A panic is never UX.
Exercises
- Add a
LineTooLong(usize)variant (cap: 256 chars) withDisplay+ a413status, enforce it inparse_line, extend the tests. - Write
fn parse_all(input: &str) -> Result<Vec<Entry>, ParseError>that fails on the FIRST bad line (all-or-nothing), with tests. Contrast with the skip-and-continue CLI. Which policy fits a batch importer? Which fits an HTTP endpoint? - Implement
From<ParseError> for Stringand use it in amap_errchain. Then defend or attackStringas an error type (hint: what canBadLevel(String)do thatStringcannot?).
Done when: cargo test -p m2-errlib is green (6+ tests) and you can explain, in
order, what happens when ? hits a mismatched error type.
Traits, Generics, and Testing Discipline
Goal: you can define a trait contract, write two implementations plus a test fake,
and choose correctly between generics (impl Trait/bounds) and trait objects (dyn).
The trait is the contract
#![allow(unused)]
fn main() {
pub trait MatchStrategy {
fn matches(&self, haystack: &str, needle: &str) -> bool;
fn name(&self) -> &'static str;
}
}
This is the shape our production code uses for swap-able behavior: uteke’s index
traits, Sinau’s StorageBackend (local vs S3 behind one interface). Callers depend on
the contract, not an implementation — which is also what makes testing cheap: the
fake IS an implementation.
Static vs dynamic dispatch
| Style | Syntax | Choose when |
|---|---|---|
| Static (generics) | fn query<S: MatchStrategy>(s: &S) or SearchEngine<S> | strategy known at compile time; zero-cost, but one monomorphized copy per type |
| Dynamic (trait object) | fn query(docs: &[Document], s: &dyn MatchStrategy) or Box<dyn MatchStrategy> | strategy chosen at runtime (config, request param); one copy, tiny vtable hop |
The M3 lab’s engine is generic over S: MatchStrategy but main stores
Box<dyn MatchStrategy> selected from CLI args — both styles in one program, and the
tests exercise both.
Iterator chains over manual loops
#![allow(unused)]
fn main() {
self.docs.iter()
.filter(|d| s.matches(&d.body, q))
.map(|d| (d.id, score_of(d)))
.filter(|(_, score)| *score > 0)
.collect()
}
Reads top-to-bottom, composes, and lazily evaluates. Reach for itertools only when a
chain genuinely needs it; most filters/maps don’t.
Testing discipline (the house rules)
- Tests live in the same file:
#[cfg(test)] mod tests— they compile against private items, and nobody forgets to update them. - Arrange–Act–Assert, one behavior per test, name = the behavior
(
word_strategy_respects_boundaries). - Every public capability gets: a happy path, an empty/edge case, and a failure mode.
- A trait contract gets a conformance test per implementation — plus a fake for tests of code that depends on the trait.
Lab
labs/m3-search — three strategies (ExactSearch, CaseInsensitiveSearch,
WordSearch), a generic SearchEngine<S> with naive title-doubled ranking, and the
&dyn variant query_dyn. Queries: cargo run -p m3-search deploy ci.
Done when: cargo test -p m3-search is green (6 tests), and you can say when
dyn costs you anything (it doesn’t here) and why the fake-free design still tests
all strategies (conformance tests).
Async Rust: Tasks, Timeouts, Backpressure
Goal: you can spawn bounded concurrent work with tokio, impose per-job timeouts, and aggregate results — without unbounded spawn or blocking the reactor.
What async actually buys
Async = many tasks waiting on IO concurrently on a few threads. The executor
(tokio) polls futures that yield at .await points. What it does not do: make
CPU work parallel (that’s rayon/threads) or make slow dependencies fast.
The vocabulary you need daily
| Concept | In the lab |
|---|---|
async fn → Future | process_job — nothing runs until polled |
.await = yield point | tokio::time::sleep(...).await |
#[tokio::main] / #[tokio::test] | starts the runtime around main / the test |
| task = spawned future | set.spawn(async move { ... }) |
JoinSet | collect results as they finish (not in spawn order) |
Semaphore | the concurrency cap — acquire before spawn, permit released on drop |
tokio::time::timeout | per-job deadline; the whole point of “fail on time” |
Backpressure or it didn’t happen
The #1 async bug in services: for x in items { tokio::spawn(job(x)) } — 100k items
= 100k in-flight tasks, memory gone, upstream hammered. The fix is a cap before
spawn:
#![allow(unused)]
fn main() {
let permit = Arc::clone(&sem).acquire_owned().await.unwrap(); // waits when full
set.spawn(async move {
let _permit = permit; // held until the task completes
// ... work ...
});
}
JoinSet::join_next() drains outcomes; sort by id afterwards if order matters.
Timeouts are part of the design
#![allow(unused)]
fn main() {
match tokio::time::timeout(Duration::from_millis(500), job).await {
Ok(Ok(ms)) => JobOutcome::Done { id, ms },
Ok(Err(())) | Err(_) => JobOutcome::TimedOut { id },
}
}
Nested Results (timeout’s + the job’s) collapse into one explicit outcome enum —
errors as values again, now async-shaped.
The one blocking trap
Never call std::thread::sleep, heavy CPU loops, or std::fs on a runtime worker —
you stall every task sharing that thread. Use tokio::time::sleep, spawn_blocking,
or tokio::fs. In M6 the only sync moment is pool setup; queries are .awaited.
Lab
labs/m4-batch — 8 simulated jobs, concurrency 4, per-job timeout; prints each
outcome plus the wall-time vs serial-sum comparison. Tests cover: all-complete,
timeout-catch, 20 jobs through a limit of 3 (no losses), and the zero-concurrency
clamp.
Done when: cargo test -p m4-batch is green (4 tests) and you can explain what
happens to the permit when a task panics (dropped — the limit self-heals).
HTTP Services with Axum
Goal: you can build a JSON REST API with axum: nested routes, extractors, shared
state, and one error enum rendered to status codes via IntoResponse.
The moving parts
| Piece | What it is | In the lab |
|---|---|---|
Router | path → handler table, nestable | /healthz, /notes, /notes/{id} |
| Extractor | typed argument a handler receives | State, Path, Json |
| Return value | impl IntoResponse | Json<T>, (StatusCode, Json<T>), or your error type |
with_state | wires shared state into every handler | AppState { notes: Arc<Mutex<Vec<_>>> } |
Handlers are plain functions — the same function works under the router and in a
#[tokio::test] with no HTTP at all. That is why the lab tests call create_note(...)
directly: transport-free unit tests on the exact production code path.
State: cheap clones, inner mutability
#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppState { notes: Arc<Mutex<Vec<Note>>> }
// Router::with_state(state) — every request gets a clone (Arc bump, not a copy)
}
Mutex::lock() is std (sync) and fine for short critical sections; under real
concurrency you’d reach for dashmap or a sqlx pool (M6). The rule that matters:
never hold a lock across an .await.
Errors: the M2 pattern on the big stage
#![allow(unused)]
fn main() {
enum ApiError { NotFound, BadRequest(String) }
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "note not found".into()),
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
type ApiResult<T> = Result<T, ApiError>; // handlers just `?` or return Err
}
Business logic returns Err(ApiError::BadRequest(...)); the boundary renders it.
Validation lives at the edge (trim, length cap) — and the 400s carry a reason.
Route syntax
axum 0.8 path params: "/notes/{id}" (curly braces replaced the old :id syntax —
old tutorials lie). One handler can serve several methods:
get(list).post(create).
Lab
labs/m5-notes-api — cargo run -p m5-notes-api, then:
curl -s localhost:3000/healthz
curl -s localhost:3000/notes
curl -s -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"title":"ship it"}'
curl -s localhost:3000/notes/1
Tests (4): roundtrip with trim, monotonic ids, empty title → 400 value, missing → 404.
Done when: tests green, and you can curl the running server through all four calls — plus explain why the handler tests need no HTTP at all.
Persistence with sqlx
Goal: you can store and query data with sqlx on SQLite: typed parameterized queries, schema-as-code, and constraints enforced by the database itself.
Why sqlx (and not an ORM)
House decision: explicit SQL, compile-time checked when used with the query macro,
no DSL to learn. Sinau LMS runs on exactly this. The lab uses the runtime API
(sqlx::query(...).bind(...)) so everything works offline; the production pattern
adds the query! macro checked against a real schema at build time.
Connect + schema-as-code
#![allow(unused)]
fn main() {
let opts = SqliteConnectOptions::from_str("sqlite:/tmp/app.db")?.create_if_missing(true);
let pool = SqlitePoolOptions::new().max_connections(5).connect_with(opts).await?;
sqlx::query("CREATE TABLE IF NOT EXISTS notes (...)").execute(&pool).await?;
}
A Pool is cloneable and shared — hand one to your axum State. Real projects put
the DDL into ordered migrations/*.sql (that is literally Sinau’s server/migrations/
layout); the idea is the same: schema is versioned code, not a GUI adventure.
The query patterns you need
#![allow(unused)]
fn main() {
// insert and get the id back — RETURNING saves a round trip
let id: i64 = sqlx::query("INSERT INTO notes (title) VALUES (?1) RETURNING id")
.bind(title).fetch_one(&pool).await?.get(0);
// list — map rows by position (or .try_get by column name)
let rows = sqlx::query("SELECT id, title FROM notes ORDER BY id")
.fetch_all(&pool).await?;
// optional single row
let maybe = sqlx::query("... WHERE id = ?1").bind(id).fetch_optional(&pool).await?;
}
Always .bind() values — never format them into the string. Positional ?1 ?2 keeps
the mapping visible.
Constraints: the DB has the last word
title TEXT NOT NULL CHECK (length(trim(title)) > 0)
The app validates for friendly 400s (M5); the constraint guarantees it even if a
future code path forgets. Defense in depth — the same reason Sinau’s migrations carry
real REFERENCES ... ON DELETE CASCADE clauses.
Lab
labs/m6-notes-db — embedded SQLite file DB, RETURNING inserts, ordered list,
optional find, and a demo of the CHECK constraint rejecting empty titles.
Tests (4): sequential ids, insert-order listing, constraint rejection (empty AND
whitespace), missing-row None.
Done when: tests green, and you can explain what fetch_one vs fetch_optional
vs fetch_all each guarantee — and which one you reach for when “not found” is a
normal outcome.
Production Hygiene: CI Gates
Goal: you can wire the house loop into GitHub Actions so nothing merges unless fmt, clippy, and tests pass — and know the release checklist by heart.
The CI file (our actual pattern)
name: CI
on:
pull_request: { branches: [develop, main] }
push: { branches: [main] }
jobs:
check: { runs-on: ubuntu-latest, steps: [ { uses: actions/checkout@v4 },
{ uses: dtolnay/rust-toolchain@stable },
{ uses: Swatinem/rust-cache@v2 },
{ run: cargo check --all-targets } ] }
format: { runs-on: ubuntu-latest, steps: [ { uses: actions/checkout@v4 },
{ uses: dtolnay/rust-toolchain@stable },
{ run: cargo fmt --all -- --check } ] }
clippy: { runs-on: ubuntu-latest, steps: [ { uses: actions/checkout@v4 },
{ uses: dtolnay/rust-toolchain@stable },
{ uses: Swatinem/rust-cache@v2 },
{ run: cargo clippy --all-targets -- -D warnings } ] }
test: { runs-on: ubuntu-latest, steps: [ { uses: actions/checkout@v4 },
{ uses: dtolnay/rust-toolchain@stable },
{ uses: Swatinem/rust-cache@v2 },
{ run: cargo test --workspace } ] }
(Condensed for reading — real repos list the steps in full YAML; the jobs and the
commands are exactly these. Swatinem/rust-cache is what keeps the re-runs fast.)
Why five separate jobs
Check/Format/Clippy/Test/Build run in parallel and report separately — a format failure doesn’t hide behind a 40-minute test queue, and the red X tells you which gate broke. Required-checks settings then refuse merges unless all five are green.
Release checklist (uteke/Sinau pattern, memorize)
CHANGELOG.mdupdated — every user-visible change, since v0.0.1- Docs in sync (README,
.env.example) - PR
develop → mainvia PR, never direct push - All 5 required checks green on the PR
- Cora review/scan before declaring done
- Tag
v0.x.yafter merge — 0.x.x forever: minor = feature, patch = fix - Push tag; CI artifacts/release from the tag
Local discipline that makes CI boring
cargo fmtbefore every commit (rustfmt may reformat files you didn’t touch — let it)- New route/module in a binary crate? Register it in both the real router and the test router — a missed registration is a 404 that only shows up in tests
- Every fix ships with the test that would have caught it
Done when: you can reproduce this CI file from memory (jobs + commands), and you can recite the release checklist without looking — that is the bar for “knows how we ship”.
Capstone: Assemble the Mini Service
Goal: combine M5 + M6 into one small but complete service: an Axum notes API backed by sqlx/SQLite, structured as a workspace — a miniature of the real fleet.
The target architecture
notes-service/
├── Cargo.toml # [workspace] members = ["server"]
└── server/
├── Cargo.toml # axum, sqlx, serde, tokio
├── migrations/ # 0001_create_notes.sql
└── src/
├── main.rs # pool setup + router + serve
├── errors.rs # AppError + IntoResponse (M2/M5 pattern)
├── routes.rs # handlers (M5 pattern)
└── db.rs # typed sqlx queries (M6 pattern)
Why a workspace for one crate: it is the seam where the capstone grows — add
crates/search (M3 engine behind a trait) or crates/ingest (M4 batch loader)
without restructuring. Real repos (uteke, sinau) all grow this way.
The assembly steps
- Schema as migration: move the lab’s
CREATE TABLEintomigrations/0001_create_notes.sql; at boot, run migrations before serving (sqlxmigrate!macro, or the manual loop from M6 for the offline version). - Share the pool through state:
AppState { pool: SqlitePool }— M5’s state slot, M6’s pool. Handlers become async and takeState<AppState>. - Swap the in-memory Vec for queries:
create_note→INSERT ... RETURNING id;get_note→fetch_optional→ok_or(ApiError::NotFound);list_notes→fetch_all. Keep the validation (trim, length) — now the DB CHECK backs it up. - Keep every M5 test: they call handlers directly; only the state constructor changes (temp-file pool instead of the in-memory Vec).
- Add the integration test: boot the router with a temp DB,
Router::oneshot()a POST + GET through the whole app — that is the “Tested means live” bar. - CI: the M7 file, unchanged. Green = done.
Evidence of done (the definition, not a feeling)
cargo test --workspacegreen: unit (handlers) + integration (oneshot roundtrip)cargo clippy --all-targets -- -D warningsclean- Live transcript: server running, four
curlcalls with correct status codes (201 → GET 200 → GET 999 → 404) - Repo with the CI file; PR merged via the checklist
Where to go next
- Add
crates/search: portm3-search’sMatchStrategybehind the notes table - Add
crates/ingest: usem4-batch’s semaphore+JoinSet to bulk-load notes - Point sqlx at Postgres: only the connect options change — that is the point
You have now built, in order: the mental model (ownership), failure handling (errors), abstraction (traits), concurrency (async), transport (axum), storage (sqlx), and the delivery pipeline (CI). That is the full loop every CodeCora service runs in.
Roadmap & Lab Index
Status
| Module | Chapter(s) | Lab | Status |
|---|---|---|---|
| Gate | Setup & Toolchain · First Program | gate-hello | ✅ live |
| M1 — Ownership | Ownership · Structs/Enums | m1-logpar | ✅ live |
| M2 — Errors | Errors as Values | m2-errlib | ✅ live |
| M3 — Traits & Testing | Traits, Generics, Testing | m3-search | ✅ live |
| M4 — Async | Tasks, Timeouts, Backpressure | m4-batch | ✅ live |
| M5 — HTTP Services | Axum API | m5-notes-api | ✅ live |
| M6 — Persistence | sqlx + SQLite | m6-notes-db | ✅ live |
| M7 — Production Hygiene | CI Gates & Release | (CI file) | ✅ live |
| M8 — Capstone | Assemble the Mini Service | your repo | guided build |
Also on lms.codecora.dev → course Rust Foundations (CodeCora Engineering Track) (private; login required): the same pilot content as structured chapters with enrollment tracking.
Capstone follow-ups (post-course, pick your depth)
crates/search— M3 engine behind a trait, indexing the notes tablecrates/ingest— M4 semaphore + JoinSet bulk loader- Postgres backend — swap connect options, keep every query pattern
- Axum middleware layer — rate limiting + request IDs (Sinau’s hardening pattern)
House rules recap
- Tested means live: green tests + behaving binary, not just “it compiles”.
- The loop:
cargo fmt && cargo clippy --all-targets -- -D warnings && cargo test. - No
unsafe, nounwrap()on user input in production paths. - One error enum per crate; render status codes at the boundary only.
- Backpressure or it didn’t happen: cap concurrency before spawning.
- English artifacts, versioned schema, CI gates everything.