Workshop · Rust · Advanced
RS300 Rust, advanced: services in production shape
Forty-eight steps across eight sessions that take Rust from a tool you know to a service you would sign off: a layered architecture with traits as boundaries, threads and tokio with timeouts and cancellation, PostgreSQL behind a repository trait with transactions, an HTTP API written on tokio's TcpListener with no web framework, integration tests over a real database, measured performance, code review including machine-written code, and the Orders capstone, an order-processing service proven by the grader's hidden inputs and tests.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick RS300 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.
Who it is for
Programmers who have finished RS100 or write Rust at work and want to build a service the way a team would ship it: layered, concurrent, persisted in PostgreSQL, reachable over HTTP, tested at every seam, measured before being optimised, and reviewed with the same care whether a colleague or a model wrote the code.
What you will be able to do
- Design a service in layers with traits as boundaries and configuration from arguments and environment, never from source
- Run work on threads and tokio tasks with channels, shared state, timeouts and cooperative cancellation
- Persist through a repository trait over PostgreSQL with parameterised statements, transactions and a hand-run migration
- Serve and consume an HTTP JSON API written on tokio::net with correct status codes and validation at the edge
- Test at every seam: fakes behind traits, integration tests over Postgres, table-driven and seeded random cases
- Measure before optimising: Instant timing, release builds, complexity and allocation, iterators versus loops
- Read, review and verify code you did not write, including machine-generated code, with a checklist and a reproduction before a fix
- Ship a service with an exit-code contract, structured logs, a reject file with reason codes and full tests
Syllabus
Every session ends in a lab the machine checks on your own code. Open a session's mini steps to read each lesson, demo and hands-on task; the badge shows how far the engine can verify it: checked · T3 a check runs over your files, proven · T4 the grader also tries inputs you did not see, tutored · T0 done on your word. Each step has its own link (for example #s1-1).
- 1. Architecture of a service
- 2. Concurrency
- 3. Persistence with PostgreSQL
- 4. An HTTP API without a framework
- 5. Testing at scale
- 6. Performance: measure first
- 7. Reading, reviewing and the AI era
- 8. Capstone: Orders
Session 1 · Architecture of a service
Six steps on shape before speed. Why a service has layers: domain in the middle, adapters at the edges, and dependencies that point inward. Traits as the boundaries between layers, so a store or a clock can be swapped in a test. Configuration from clap arguments and environment variables with defaults, validated once at start-up, and the rule that secrets never appear in source or logs. Structured logging to stderr with a level and fields. The exit-code contract as part of the architecture, not an afterthought. Live in the seat: a quote service split into domain, ports and adapters, a Config loaded from env and args, a log line with fields, and a wiring main of twenty lines.
Lab The layered quote service in rust/rs300/quote: a domain module with pure pricing, a QuoteStore trait with a file adapter and an in-memory fake, Config from clap and env with validation, structured logs on stderr, a thin main with exit codes 0/2/3, and tests that exercise the domain without touching a file. Session 1 checkpoint.
6 mini steps · ~53 min
1.1Layers and the dependency rule9 minchecked · T3
Predict
The domain module imports std::fs to save quotes. Which rule is broken?
Lesson
Three layers: domain (types and rules, pure), ports (traits the domain needs: a store, a clock, a notifier), adapters (file, Postgres, HTTP, CLI). Dependencies point inward: adapters know the domain; the domain knows nothing about them. main wires them together. This is what lets tests run without a disk or a network.
Demo
The tutor opens rust/rs300/demo/quote, walks the module tree — domain.rs, ports.rs, adapters/file.rs, main.rs — and shows that domain.rs has no use statements beyond std::collections and the crate's own types.
Hands-on
Create rust/rs300/quote as a lib + bin. Add src/domain.rs with pub struct Quote { id: u64, lines: Vec<Line>, total_cents: i64 }, pub struct Line, and pub fn price(lines: &[Line]) -> i64 (sum of qty × unit_cents). src/ports.rs and src/adapters/mod.rs empty for now. No I/O in domain.rs.
Explain
State the dependency rule in one sentence and give one violation.
Check
Domain module with no I/O — checked (T3):
seat_fileseat_fileseat_build1.2Traits as ports9 minchecked · T3
Predict
pub trait QuoteStore { fn save(&mut self, q: &Quote) -> Result<(), StoreError>; } Who implements it?
Lesson
A port is a trait the domain or application layer declares: QuoteStore with save and load, Clock with now. Adapters implement it. Application code takes &mut dyn QuoteStore or a generic S: QuoteStore, so the same code runs against a file, a database or a Vec in a test. Errors cross the port as a small enum, not as io::Error.
Demo
The tutor writes QuoteStore and Clock in ports.rs, a MemoryStore in adapters/memory.rs backed by a Vec, and an application function fn create_quote(store: &mut dyn QuoteStore, clock: &dyn Clock, lines) -> Result<Quote, AppError>, tested with the memory store.
Hands-on
In src/ports.rs declare pub trait QuoteStore { fn save(&mut self, q: &Quote) -> Result<(), StoreError>; fn load(&self, id: u64) -> Result<Option<Quote>, StoreError>; } and pub trait Clock { fn now(&self) -> chrono::NaiveDateTime; }. Implement MemoryStore in src/adapters/memory.rs. Write pub fn create_quote in src/app.rs using both ports; test it with MemoryStore and a FixedClock.
Explain
Why does the application code take dyn QuoteStore rather than MemoryStore or FileStore?
Check
Ports declared, memory adapter, app tested — checked (T3):
seat_fileseat_fileseat_fileseat_test1.3A file adapter behind the same port8 minchecked · T3
Predict
FileStore::load for an id that was never saved returns?
Lesson
A second adapter proves the port: FileStore keeps quotes as JSON files under a folder, maps io and serde errors into StoreError, and returns Ok(None) for an unknown id. The application code does not change by a character. Adapters own the translation of external errors into the port's small enum.
Demo
The tutor writes adapters/file.rs with FileStore { dir: PathBuf }, save writing <dir>/<id>.json and load reading it, then runs the same app tests against FileStore in a temp folder to show both adapters pass.
Hands-on
Add src/adapters/file.rs with pub struct FileStore and impl QuoteStore using serde_json files under a folder passed to FileStore::new. Map errors into StoreError { Io, Corrupt }. Add tests tests::file_store_round_trip and tests::file_store_missing_is_none using a temp folder under target/.
Explain
What did you have to change in app.rs to support the file store?
Check
FileStore implements the port; app unchanged — checked (T3):
seat_fileseat_test1.4Configuration: args, env, defaults, validation9 minproven · T4
Predict
QUOTE_DIR is unset and --dir is not passed; Config has a default of ./quotes. What does the service use, and when does it validate it?
Lesson
Configuration flows in from arguments and environment: clap with #[arg(long, env = "QUOTE_DIR", default_value = "./quotes")] does all three. Validate the whole Config once at start-up — the folder exists or can be created, the port is in range — and exit 2 with a clear message if not. Secrets (a database password) come only from env and are never printed.
Demo
The tutor writes Config with dir, log_level and a db_url: Option<String> from env only, implements Debug by hand to redact db_url, validates at start, and runs with a bad dir to show exit 2.
Hands-on
Add src/config.rs with #[derive(Parser)] pub struct Config { #[arg(long, env = "QUOTE_DIR", default_value = "./quotes")] pub dir: PathBuf, #[arg(long, env = "QUOTE_LOG", default_value = "info")] pub log: String, #[arg(long, env = "QUOTE_DB_URL", hide_env_values = true)] pub db_url: Option<String> } with pub fn validate(&self) -> Result<(), String>. Print the config with db_url redacted as "***".
Explain
Name two places a secret must never appear and how your Config prevents each.
Check
Config from args/env with defaults, validation and redaction — proven (T4):
seat_fileseat_run1.5Structured logging and the exit-code contract8 minchecked · T3
Predict
A log line is written with println!. A script captures the service's report from stdout. What goes wrong?
Lesson
Without a logging crate in the seat, a thirty-line logger is enough: a Level enum, a log! macro that writes one line per event to stderr as "ts=... level=info event=quote.saved id=42 ms=3", filtered by the configured level. Exit codes are declared once as constants and mapped from the top-level error in main. Nothing but the report touches stdout.
Demo
The tutor writes src/log.rs with a level filter and a macro, logs start-up, each save and each failure with fields, then shows the exit-code map in main and runs three scenarios reading echo $?.
Hands-on
Add src/log.rs with pub enum Level { Error, Warn, Info, Debug }, a global filter set from Config, and a macro log!(level, event, k = v, ...) that writes key=value pairs to stderr. Log service.start and quote.saved with id and ms. Declare EXIT_OK 0, EXIT_CONFIG 2, EXIT_INPUT 3 in main.rs.
Explain
Why key=value fields rather than a sentence in a log line?
Check
Structured logs on stderr; exit codes declared — checked (T3):
seat_fileseat_fileseat_build1.6Lab: the layered quote service10 minchecked · T3
Predict
quote new reads lines from a CSV argument. A line has qty "two". Which exit code, and does any file get written?
Lesson
Everything from this session as a working service: cargo run -- new <lines.csv> prices the lines, saves a quote through the FileStore under Config.dir and prints "Quote <id> total <units.cents>"; cargo run -- show <id> prints it or exits 3 if unknown; config errors exit 2. Logs on stderr; domain and app tests run with the memory store only.
Demo
The tutor completes the demo service, runs new over samples/lines.csv and show over the id, shows the JSON file in the folder, then runs the test suite and points out that no test touches the configured folder.
Hands-on
Finish rust/rs300/quote: subcommands new <csv> and show <id> wired through app.rs with FileStore; "Quote 1 total 19.74" for samples/lines.csv (ids increase from 1 per folder); show 99 exits 3; bad qty exits 3 with no file written; tests green and clippy clean.
Explain
Which layer would change, and which would not, if quotes moved to PostgreSQL?
Check
Quote service: layered, configured, logged, exit codes honoured — checked (T3):
seat_testseat_lintseat_runseat_runseat_runseat_runcraft
Session 2 · Concurrency
Six steps on doing several things at once without data races. std::thread::scope and channels: workers that send results back, and why the compiler refuses a shared &mut across threads. Arc<Mutex<T>> for shared state, the lock guard as RAII, and the poisoned-lock case. tokio: async fn, await, spawn, join, and what the runtime actually does with a task. Timeouts with tokio::time::timeout and the rule that every wait has a deadline. Cooperative cancellation with a watch or a CancellationToken built from a Notify, and shutting down cleanly. Live in the seat: eight threads pricing quotes through a channel, a counter behind a Mutex, the same job on tokio tasks, a timeout that fires, and a batch runner that stops on Ctrl-C with every file accounted for.
Lab The concurrent batch runner in rust/rs300/batch: price every CSV in a folder on a bounded number of tokio tasks, each with a timeout, collect results through a channel into a summary sorted by file name, stop cooperatively on a cancel signal, and exit 0 when all succeed, 4 if any file failed or timed out. Hidden folders prove it. Session 2 checkpoint.
6 mini steps · ~53 min
2.1Threads, scope and channels9 minchecked · T3
Predict
Two threads each do total += 1 on a shared mut i64 captured by reference. What does rustc say?
Lesson
std::thread::scope spawns threads that borrow from the enclosing scope and are joined before it ends. Results travel back through std::sync::mpsc channels: each worker owns a Sender, the parent drains the Receiver until every sender is dropped. Shared &mut across threads is refused at compile time; that refusal is Rust's whole promise about data races.
Demo
The tutor prices eight CSV files on eight scoped threads in rust/rs300/demo/batch, sending (name, Result<i64>) through a channel, and shows the compile error from the predict question before fixing it with a channel.
Hands-on
Create rust/rs300/batch. In src/main.rs read the file names in samples/quotes/, spawn one scoped thread per file that sums the cents column and sends (name, total) through an mpsc channel; print each result sorted by name and "Files 4". No shared mutable state.
Explain
Why is a channel the first tool to reach for, before a Mutex?
Check
Scoped threads and a channel price four files — checked (T3):
seat_fileseat_run2.2Arc<Mutex<T>> and the lock guard8 minchecked · T3
Predict
let g = counter.lock().unwrap(); *g += 1; then the thread does slow work for a second before g drops. Effect on other threads?
Lesson
When state must be shared, wrap it: Arc for shared ownership across threads, Mutex for one-at-a-time access. lock() returns a guard that releases on drop, so scope the guard tightly and never hold it across slow work or an await. A panic while holding poisons the lock; unwrap on lock() is the honest choice when poison means a bug.
Demo
The tutor adds a shared Arc<Mutex<Stats>> with files_ok and cents_total updated by each worker, shows the guard scoped in a block, then deliberately holds it during a sleep and times the slowdown.
Hands-on
Add pub struct Stats { files_ok: u32, files_failed: u32, cents_total: i64 } behind Arc<Mutex<Stats>> updated by each thread in a block that holds the guard only for the update. Print "Stats ok=4 failed=0 total=6299" after the join. Keep the channel for per-file results.
Explain
What two things does the guard's Drop guarantee?
Check
Shared Stats behind Arc<Mutex> updated in tight blocks — checked (T3):
seat_fileseat_run2.3tokio: async, await, spawn, join9 minchecked · T3
Predict
async fn price(f) is called as price("a.csv"); without .await or spawn. What runs?
Lesson
tokio runs many tasks on a few threads. async fn returns a future; .await yields while waiting; tokio::spawn starts a task that runs concurrently and returns a JoinHandle you await. Blocking work (CPU, std file I/O) goes in spawn_blocking so it does not stall the runtime. #[tokio::main] builds the runtime for you.
Demo
The tutor adds tokio to the demo, rewrites the batch as async fn price_file with tokio::fs::read_to_string, spawns one task per file, awaits the JoinHandles, and shows the unused-future warning from the predict question.
Hands-on
Add tokio (features full) to Cargo.toml. Rewrite the runner as #[tokio::main] async fn main with async fn price_file(path) -> anyhow::Result<i64> using tokio::fs; spawn a task per file, collect through a tokio::sync::mpsc channel, print the same four lines and "Files 4".
Explain
When is spawn_blocking required inside an async program?
Check
tokio tasks price the files — checked (T3):
seat_fileseat_fileseat_run2.4Timeouts: every wait has a deadline8 minproven · T4
Predict
tokio::time::timeout(Duration::from_millis(50), slow()).await where slow takes 200 ms returns?
Lesson
An await without a deadline is a hang waiting to happen. tokio::time::timeout wraps any future with a Duration and yields Result<T, Elapsed>. Decide the deadline per operation, make it configurable, and treat Elapsed as a normal failure with its own reason code. Dropping the inner future cancels it.
Demo
The tutor adds a 100 ms timeout around price_file, points it at samples/quotes-slow/ where one file is huge, and shows the Elapsed result counted as a failure while the others finish.
Hands-on
Wrap each price_file call in tokio::time::timeout with a --timeout-ms argument (default 1000). A timed-out file reports "<name> timeout" and counts as failed. Run on samples/quotes/ (all ok) and with --timeout-ms 1 on samples/quotes-slow/ (one timeout).
Explain
What happens to the slow future when the timeout fires?
Check
Per-file timeout counted as failure — proven (T4):
seat_fileseat_run2.5Cooperative cancellation and clean shutdown9 minproven · T4
Predict
A cancel signal arrives while 3 of 10 tasks are running. With cooperative cancellation, how many results does the summary account for?
Lesson
Cancellation must be cooperative: a tokio::sync::watch<bool> (or a Notify-based token) that every task checks with select! between its work and the signal. On cancel, running tasks finish their current await and report "cancelled"; unstarted ones are skipped and reported; the runner still prints a full summary. Ctrl-C is just one source of the signal.
Demo
The tutor adds a watch channel, selects between price_file and cancel.changed() in each task, wires tokio::signal::ctrl_c to flip it, runs over samples/quotes-slow/ and presses Ctrl-C, reading the labelled summary.
Hands-on
Add a tokio::sync::watch<bool> cancel flag flipped by tokio::signal::ctrl_c and by a --cancel-after-ms test flag. Each task selects between its work and the flag; cancelled files print "<name> cancelled". The summary always lists every file. Exit 4 if any file is not ok.
Explain
Why can't the runtime just kill a task?
Check
Cancellation labels every file — proven (T4):
seat_fileseat_run2.6Lab: the concurrent batch runner10 minproven · T4
Predict
--parallel 2 with 8 files. How many price_file futures run at the same moment, at most?
Lesson
Everything from this session: a bounded number of tasks via Semaphore (--parallel), a per-file timeout, cooperative cancellation, results through a channel into a summary sorted by name with ok / failed / timeout / cancelled labels, shared Stats behind Arc<Mutex>, exit 0 when every file is ok and 4 otherwise. The grader runs it over folders you did not see.
Demo
The tutor completes the demo runner, runs it with --parallel 1, 2 and 8 over twenty files timing each, then over a folder with a bad file and reads the labelled summary and exit code.
Hands-on
Finish rust/rs300/batch: cargo run -- [--parallel N] [--timeout-ms T] <folder> prints one "<name> <total|failed|timeout|cancelled>" per file sorted by name, then "Files <n>" and "Stats ok=.. failed=.. total=..". A folder with a bad CSV exits 4. Clippy clean; a unit test for the summary sort.
Explain
List the three failure kinds your runner distinguishes and why a consumer wants them apart.
Check
Batch runner over hidden folders — proven (T4):
seat_fileseat_lintseat_testseat_run
Session 3 · Persistence with PostgreSQL
Six steps on data that outlives the process. The seat's PostgreSQL 17 and the postgres crate: a Client from a connection string that comes only from the environment. A schema written as a migration file and applied by hand with a tiny runner that records what it applied. Parameterised statements, never string concatenation, and the type mapping between Rust and SQL including Decimal for money. Transactions: begin, several statements, commit or roll back, and why a transfer needs one. The repository trait from session 1 implemented over Postgres, so the application code does not change. Integration tests that create their own schema and clean up. Live in the seat: psql in the seat terminal, a migration applied twice without harm, a parameterised insert, a rolled-back transfer, and the Postgres-backed store lab.
Lab The Postgres-backed repository in rust/rs300/store: a migrations/ folder with a runner, a QuoteStore implementation over the postgres crate with parameterised SQL and a transaction around save, Decimal money, integration tests against the seat's database that create and drop their own schema, and the quote service wired to it. Session 3 checkpoint.
6 mini steps · ~53 min
3.1Connecting: the client and the URL from env8 minchecked · T3
Predict
The connection string is written in main.rs as a literal. Which rule from session 1 is broken, even on a dev box?
Lesson
postgres::Client::connect(&url, NoTls) opens a connection; the URL (host, user, database, password) comes from env — STORE_DB_URL in this course — and is never printed. The seat runs PostgreSQL 17 locally; psql in the seat terminal is your second window onto the same data. One client per thread; close by dropping.
Demo
The tutor reads STORE_DB_URL in rust/rs300/demo/store, connects, runs SELECT version(), prints the first word, then opens psql in the seat terminal and runs the same query to show two views of one database.
Hands-on
Create rust/rs300/store as a lib + bin with postgres and rust_decimal (db-postgres) in Cargo.toml. Read STORE_DB_URL from env (exit 2 if unset), connect, run SELECT 1 + 1, and print "Connected, 1 + 1 = 2". Never print the URL.
Explain
What does your program do if STORE_DB_URL is unset, and why that rather than a default?
Check
Connects from env, prints 2, never prints the URL — checked (T3):
seat_fileseat_fileseat_run3.2Migrations by hand, recorded9 minchecked · T3
Predict
The migration runner is run twice. The second run should?
Lesson
Schema changes are files: migrations/0001_quotes.sql, 0002_lines.sql, applied in order by a small runner that keeps a schema_migrations(name, applied_at) table and skips what it has seen. Each migration runs in a transaction. No ORM, no magic: the SQL is in the repository where reviewers can read it.
Demo
The tutor writes migrations/0001_quotes.sql (quotes: id bigserial, created_at timestamptz, total_cents bigint) and 0002_lines.sql, the runner in src/migrate.rs, runs cargo run -- migrate twice, and shows the second run applying nothing.
Hands-on
Add migrations/0001_quotes.sql and 0002_lines.sql (lines: id, quote_id references quotes, sku text, qty int, unit_cents bigint). Write src/migrate.rs with pub fn migrate(client: &mut Client) -> Result<usize, Error> that applies unapplied files in a transaction each and records them. cargo run -- migrate prints "Applied 2" then "Applied 0".
Explain
Why one transaction per migration?
Check
Idempotent migration runner — checked (T3):
seat_fileseat_fileseat_fileseat_runseat_run3.3Parameterised statements and type mapping9 minchecked · T3
Predict
format!("INSERT INTO lines (sku) VALUES ('{}')", sku) with sku = "A1'); DROP TABLE lines; --". What happens?
Lesson
client.execute("INSERT ... VALUES ($1, $2)", &[&sku, &qty]) sends values out of band; the SQL text never changes. Types map: i64 to bigint, i32 to int, &str to text, chrono::NaiveDateTime to timestamp, rust_decimal::Decimal to numeric with the db-postgres feature. Read rows with row.get::<_, i64>("total_cents") and let the type mismatch fail loudly.
Demo
The tutor inserts a quote and three lines with parameters, reads them back into structs, shows the predict question's payload stored harmlessly as text, then inserts a Decimal total into a numeric column and reads it back exact.
Hands-on
In src/repo.rs write pub fn insert_quote(client: &mut Client, total: Decimal) -> Result<i64, Error> returning the new id via RETURNING id, and pub fn insert_line(client, quote_id, sku: &str, qty: i32, unit_cents: i64). Add a test that stores the sku "A1'); DROP TABLE lines; --" and reads it back unchanged.
Explain
Why can a parameter never become SQL?
Check
Parameterised inserts, injection test, Decimal round trip — checked (T3):
seat_fileseat_testcraft3.4Transactions8 minchecked · T3
Predict
Inside a transaction, the quote row is inserted, then the second line insert fails, and the Transaction is dropped without commit. The quote row?
Lesson
client.transaction()? returns a Transaction that behaves like a client; commit() makes it permanent; dropping it rolls back. Saving a quote with its lines is one transaction: all rows or none. The ? operator inside it gives you rollback on every error path for free, which is RAII again.
Demo
The tutor wraps insert_quote and the line inserts in a transaction in save_quote, then injects a failing line and shows with psql that no quote row exists afterwards, then removes the fault and commits.
Hands-on
Write pub fn save_quote(client: &mut Client, q: &Quote) -> Result<i64, Error> in repo.rs: one transaction, the quote row, every line, commit. Test tests::failed_line_rolls_back_quote by making a line violate a constraint (qty check) and asserting the quotes count is unchanged.
Explain
Why is rollback-on-drop safer than an explicit rollback call?
Check
save_quote is atomic — checked (T3):
seat_fileseat_test3.5The repository trait over Postgres9 minchecked · T3
Predict
PgStore implements QuoteStore. How many lines of app.rs from session 1 change?
Lesson
Copy the QuoteStore and Clock ports from session 1 into this crate (or depend on it). PgStore { client } implements save via save_quote and load via a two-query read, mapping postgres::Error into StoreError. The application and its tests stay as they were; only main chooses PgStore over FileStore.
Demo
The tutor adds the ports and app modules from quote, writes impl QuoteStore for PgStore, runs the memory-store unit tests unchanged, then wires main with PgStore and creates a quote end to end.
Hands-on
Add src/ports.rs and src/app.rs from rust/rs300/quote. Write src/adapters/pg.rs with pub struct PgStore { client: Client } and impl QuoteStore for PgStore (save via save_quote, load via SELECT with the lines). main: cargo run -- new samples/lines.csv prints "Quote <id> total 19.74" using PgStore.
Explain
What is the one place that knows both the application and the database adapter?
Check
PgStore behind the port; app unchanged; end-to-end new — checked (T3):
seat_fileseat_fileseat_run3.6Lab: the Postgres-backed repository10 minchecked · T3
Predict
Integration tests run in parallel against one database and each creates table quotes. What goes wrong, and what is the fix?
Lesson
Everything from this session as a finished crate: migrations and runner, PgStore behind the port, parameterised SQL only, Decimal money, transactions around save, and integration tests in tests/pg.rs that each create a schema, set search_path, migrate, run and drop the schema in a Drop guard, so they pass in parallel and leave nothing behind.
Demo
The tutor writes tests/pg.rs with a SchemaGuard, runs cargo test twice to show no leftovers, then lists schemas in psql while tests run to show them appear and vanish.
Hands-on
Finish rust/rs300/store: tests/pg.rs with a SchemaGuard (create schema, set search_path, migrate, drop on Drop) and tests save_then_load_round_trip, load_missing_is_none and rollback_leaves_no_rows; all SQL parameterised; clippy clean; the bin's new/show/migrate commands work.
Explain
Describe the life of one integration test's schema.
Check
Postgres repository with isolated integration tests — checked (T3):
seat_fileseat_testseat_lintseat_runcraft
Session 4 · An HTTP API without a framework
Six steps on speaking HTTP from first principles, because the seat has tokio and serde_json and no web framework. The protocol as text: request line, headers, blank line, body; status line and headers back. A tokio::net::TcpListener accepting connections and a task per connection. Parsing the request line and Content-Length safely with limits, and answering 400 for anything malformed. Routing by method and path to handlers that return typed results, with JSON in and out through serde_json and the right status codes: 200, 201, 400, 404, 405, 500. Validation at the edge. A client with std::net::TcpStream to test the server from outside. Live in the seat: a raw request typed into a socket, a server that echoes the request line, a POST /quotes that creates and returns 201, a 404 and a 400, and the hand-rolled API lab.
Lab The hand-rolled HTTP API in rust/rs300/api: GET /health, POST /quotes (JSON lines in, 201 with the quote out), GET /quotes/{id} (200 or 404), 405 for other methods, 400 for bad JSON or a body over the limit, all over tokio::net with the QuoteStore port behind it, a graceful shutdown, and integration tests that start the server on an ephemeral port and call it with a TcpStream client. Hidden requests prove it. Session 4 checkpoint.
6 mini steps · ~54 min
4.1HTTP as text8 minchecked · T3
Predict
Which line separates HTTP headers from the body?
Lesson
An HTTP/1.1 request is text: "POST /quotes HTTP/1.1", header lines "Name: value", an empty line, then Content-Length bytes of body. The response mirrors it: "HTTP/1.1 201 Created", headers, empty line, body. Knowing the bytes is what lets you write the server and read the framework docs with understanding later.
Demo
The tutor opens a raw TCP connection from a tiny Rust client to a listening socket in the seat, types a request line by line, and shows exactly which bytes arrive, then sends a response by hand and watches the client print it.
Hands-on
Create rust/rs300/api as a lib + bin with tokio and serde_json. Write src/bin/rawclient.rs: arguments <addr> [method path [body]] (default GET /health); connect with std::net::TcpStream, send the request line, Host and Content-Length headers, a blank line and the body, read until the socket closes and print the response. Target the demo server for now.
Explain
Why does the server need Content-Length to read a body?
Check
A raw client sends a request and prints the status line — checked (T3):
seat_fileseat_build4.2Accepting connections on tokio9 minchecked · T3
Predict
The accept loop awaits handle(conn) directly instead of spawning. A client connects and stalls. Effect on others?
Lesson
tokio::net::TcpListener::bind("127.0.0.1:0") picks a free port you can read back; loop { let (stream, _) = listener.accept().await?; tokio::spawn(handle(stream)); }. Inside handle, a BufReader over the stream reads lines until the empty one. Bind to loopback in the seat; port 0 is how tests avoid collisions.
Demo
The tutor writes the accept loop in rust/rs300/demo/api/src/server.rs, a handler that reads the request line and writes "HTTP/1.1 200 OK" with a body echoing it, prints the bound port, and connects two raw clients at once.
Hands-on
Write src/server.rs with pub async fn serve(listener: TcpListener, store: SharedStore) -> anyhow::Result<()> that spawns a task per connection, and src/http.rs with a handler that reads the request line and replies 200 with the body "echo: <request line>". main binds 127.0.0.1:8085 or --port.
Explain
Why bind to port 0 in tests?
Check
Accept loop spawns per connection; echo reply — checked (T3):
seat_fileseat_fileseat_build4.3Parsing the request safely9 minchecked · T3
Predict
A client sends Content-Length: 99999999999 and one byte. Without a limit, what does a naive server do?
Lesson
Parse into a Request { method, path, headers, body } with limits: request line at most 8 KB, at most 64 headers, body at most 64 KB, Content-Length a valid number, a read timeout on the socket. Anything else is 400 Bad Request without panicking. The parser is a pure function over bytes and is tested with a table of nasty inputs.
Demo
The tutor writes parse_request(reader) -> Result<Request, HttpError> in http.rs with the limits, then feeds it a table of bad inputs — no blank line, a huge length, a non-numeric length, 100 headers — and shows each become a 400.
Hands-on
In src/http.rs write pub struct Request and pub async fn parse_request<R: AsyncBufRead + Unpin>(r: &mut R) -> Result<Request, HttpError> with the limits from the lesson and a 5-second timeout. Tests tests::parses_post_with_body, tests::rejects_huge_length, tests::rejects_bad_length, tests::rejects_too_many_headers.
Explain
Name three limits your parser enforces and the status each produces.
Check
Request parser with limits and a nasty-input table — checked (T3):
seat_fileseat_test4.4Routing, JSON and status codes9 minchecked · T3
Predict
POST /quotes with valid JSON creates a quote. Which status, and what header tells the client where it is?
Lesson
A router is a match on (method, path segments): ("GET", ["health"]) → 200 {"ok":true}; ("POST", ["quotes"]) → parse JSON body, validate, create, 201 with Location; ("GET", ["quotes", id]) → 200 or 404; a known path with another method → 405 with Allow; anything else → 404. Handlers return Result<Response, HttpError>, and one function turns HttpError into a status and a JSON error body.
Demo
The tutor writes the router and the three handlers over the MemoryStore from session 1 behind Arc<Mutex<dyn QuoteStore>>, serialises the Quote with serde_json, and exercises every status with the raw client.
Hands-on
Write src/router.rs with pub async fn route(req: Request, store: SharedStore) -> Response implementing the five cases with the exact statuses, JSON error bodies {"error": "..."} and a Location header on 201. Wire it in server.rs. Content-Type: application/json on every JSON response.
Explain
Why 405 rather than 404 for DELETE /quotes/1?
Check
Router with 200/201/400/404/405 and JSON bodies — checked (T3):
seat_fileseat_build4.5Testing the server from outside9 minchecked · T3
Predict
An integration test starts the server on port 0 and must stop it at the end. Which mechanism from session 2 fits?
Lesson
Integration tests start the real server on 127.0.0.1:0 inside the test's tokio runtime, read the port, and call it with a small client (std TcpStream in spawn_blocking or tokio TcpStream) that sends bytes and parses the status line and body. A shutdown signal stops the accept loop. Each test gets its own server and MemoryStore, so they run in parallel.
Demo
The tutor writes tests/api.rs with a start_server() helper returning (addr, shutdown), a send(addr, request) helper, and tests for health, create-then-get, 404 and 400, runs them in parallel and shows the shutdown releasing the port.
Hands-on
Add a shutdown watch to serve(). Write src/test_support.rs with pub async fn start_server() -> (String, tokio::sync::watch::Sender<bool>) that binds port 0 with a MemoryStore and returns the address. Write tests/api.rs using it with tests health_ok, create_then_get, get_missing_is_404, bad_json_is_400, wrong_method_is_405.
Explain
Why test through the socket rather than calling route() directly?
Check
Five socket-level integration tests with clean shutdown — checked (T3):
seat_fileseat_fileseat_fileseat_test4.6Lab: the hand-rolled HTTP API10 minproven · T4
Predict
POST /quotes with a body of {"lines": []} (no lines). Which status?
Lesson
Everything from this session as a finished API: tokio accept loop with a shutdown signal, a limited parser, the router with exact statuses and headers, validation of the quote body (one or more lines, qty > 0, unit_cents >= 0), the QuoteStore port behind Arc<Mutex>, structured logs with method, path, status and ms, exit 2 if the port cannot be bound. The grader fires requests you did not see.
Demo
The tutor completes the demo API, runs the server on 8085 and the grader's request script against it, reading each status, then stops the server with Ctrl-C and shows the clean shutdown log line.
Hands-on
Finish rust/rs300/api: validation rules, log lines per request, --port with exit 2 when busy, all tests green, clippy clean. Then run cargo run -- --port 8085 and press Check: the grader connects to 127.0.0.1:8085 with its own requests.
Explain
Which three things does your edge validate before the domain sees a request, and why there?
Check
API: tests, lint, and the grader's hidden requests — proven (T4):
seat_testseat_lintseat_runcraft
Session 5 · Testing at scale
Six steps on proof that keeps up with a growing service. The test pyramid in Rust terms: fast unit tests on the domain, seam tests with fakes behind the ports, a few integration tests over Postgres and the socket. Fakes that record calls and inject failures, so error paths get tested too. Table-driven tests generated from a specification file. Seeded random cases with rand: hundreds of inputs, a fixed seed, and a shrink-by-hand habit when one fails, because proptest is not in the seat. Coverage by reasoning: a checklist of paths per function, since no coverage tool runs for Rust here. Test organisation: a tests/common module, fixtures, and names that read as sentences. Live in the seat: a failing store fake proving the rollback path, a spec table loaded from CSV, a seeded fuzz over the parser, and the seam-tested service lab.
Lab The seam-tested service in rust/rs300/svc: the quote application with a recording fake store and a failing fake, spec-driven table tests from tests/spec/*.csv, a seeded random test over the HTTP parser with 500 cases, a tests/common fixture module, and the grader's hidden tests through the same seams. Session 5 checkpoint.
6 mini steps · ~52 min
5.1The pyramid, in Rust terms8 minchecked · T3
Predict
A rule "a quote needs at least one line" is tested only through the HTTP socket. What is the cost when it fails?
Lesson
Three layers of tests mirror the three layers of code: many unit tests on pure domain functions (microseconds), seam tests of the application with fakes for every port (milliseconds), a few integration tests over Postgres and the socket (seconds). A rule is tested where it lives; outer tests prove the wiring, not the rule again.
Demo
The tutor opens rust/rs300/demo/svc, counts the tests per layer, times cargo test per layer with --test and --lib, and moves one rule test from the socket layer down to the domain to show the speed-up.
Hands-on
Create rust/rs300/svc from your session 1 quote crate (domain, ports, app, adapters). Write a tests/LAYERS.md listing each existing test under Unit, Seam or Integration, and add at least two domain unit tests for validation rules (no lines; qty 0) in src/domain.rs.
Explain
Where does a new business rule get its first test, and why?
Check
Layered test inventory plus two domain rule tests — checked (T3):
seat_fileseat_test5.2Fakes that record and fakes that fail9 minchecked · T3
Predict
FailingStore::save always returns Err(StoreError::Io). create_quote is called. What should the test assert?
Lesson
A fake implements a port in memory. A recording fake pushes every call into a Vec so a test asserts what happened and in what order. A failing fake returns the error you choose, which is the only practical way to test error paths. Both live under tests/common or a test-only module and are a few lines each.
Demo
The tutor writes RecordingStore (Vec<Call>) and FailingStore(StoreError) in tests/common/mod.rs, then tests create_quote: the happy path asserts one save with the right total; the failing path asserts AppError::Store and no second attempt.
Hands-on
Add tests/common/mod.rs with RecordingStore { calls: Vec<String> } and FailingStore(StoreError) implementing QuoteStore. Write tests/app_seams.rs: save_called_once_with_total, store_failure_maps_to_app_error, load_not_called_on_create. Run with cargo test --test app_seams.
Explain
Why is a failing fake the only honest way to test the rollback path?
Check
Recording and failing fakes drive seam tests — checked (T3):
seat_fileseat_test5.3Spec-driven table tests8 minchecked · T3
Predict
The pricing spec table is a CSV the product owner edits. A row is added. What must change in the test code?
Lesson
When a rule has many cases, keep them in a file next to the tests: tests/spec/pricing.csv with columns qty, unit_cents, promo, expected. One test reads it with the csv crate, runs every row and asserts with a message naming the row. The file is readable by non-programmers and reviewed like code.
Demo
The tutor writes tests/spec/pricing.csv with twelve rows, tests/spec_pricing.rs that loads and runs them, breaks one expected value to show the row-naming failure, and fixes it.
Hands-on
Add tests/spec/pricing.csv with at least twelve rows covering quantity breaks, promo and the minimum charge, and tests/spec_pricing.rs with one test pricing_spec_table that runs every row through domain::price_line and names the failing row. Add csv as a dev-dependency.
Explain
What makes a CSV table better than twelve test functions here?
Check
Twelve-row spec table drives one test — checked (T3):
seat_fileseat_fileseat_test5.4Seeded random cases without proptest9 minchecked · T3
Predict
A random test uses rand::rng() with no seed and fails once in fifty runs. What is the first fix?
Lesson
Property-style testing by hand: generate hundreds of inputs with rand::rngs::StdRng::seed_from_u64(SEED), check an invariant for each (the parser never panics and returns Ok or a 400-class error; price(lines) equals the sum computed another way), and on failure print the seed and the input. Shrink by hand: cut the input in half until it still fails.
Demo
The tutor writes a 500-case seeded fuzz over parse_request with random bytes, headers and lengths, asserting no panic and a Result, then a price invariant test comparing fold against a manual loop, showing the seed in the failure message.
Hands-on
Add rand as a dev-dependency. Write tests/random.rs: parser_never_panics (500 random requests through http::parse_request, result is Ok or Err, never a panic) and price_matches_manual_sum (300 random line sets), both seeded with a const SEED and printing it on failure.
Explain
What invariant does the parser fuzz check, and why is that enough?
Check
Seeded random tests over the parser and pricing — checked (T3):
seat_fileseat_fileseat_test5.5Coverage by reasoning and test organisation8 minchecked · T3
Predict
fn adjust has three branches: unknown sku, below zero, ok. Tests cover ok and unknown. Coverage by reasoning says?
Lesson
No coverage tool runs for Rust in the seat, so coverage is a discipline: for each public function, list its paths (each branch, each error) in a comment or in tests/COVERAGE.md and name the test that proves each. Organise tests so names read as sentences and fixtures live in tests/common. A reviewer should find the test for any path in under a minute.
Demo
The tutor writes tests/COVERAGE.md for the demo svc with a row per path and a test name, finds one path with no test, writes it, and renames two tests so they read as sentences.
Hands-on
Write tests/COVERAGE.md with a table: function, path, test name, for every pub function in domain.rs and app.rs. Every row must name an existing test; add tests for any uncovered path. Rename tests so cargo test output reads as sentences.
Explain
What does the coverage table catch that a percentage would not?
Check
Coverage table names a test per path — checked (T3):
seat_fileseat_test5.6Lab: the seam-tested service10 minproven · T4
Predict
The grader's hidden test constructs its own failing store that fails on the second save. What must your application expose for that to work?
Lesson
Everything from this session as a finished test suite: unit tests on domain rules, seam tests with recording and failing fakes, the spec table, seeded random tests, a coverage table, and sentence-shaped names. The grader adds hidden seam tests using its own fakes through your ports, which is why the ports must be stable and public.
Demo
The tutor runs the whole demo suite with --test-threads 8, reads the timing per layer, then drops in a hidden-style test with a store that fails on the second call and shows it passing against the demo application.
Hands-on
Finish rust/rs300/svc: every port pub, create_quote and load_quote in app.rs accepting &mut dyn QuoteStore and &dyn Clock, all layers of tests green, COVERAGE.md complete, clippy clean. Press Check and read the hidden test names: each describes a seam case.
Explain
Explain the test pyramid of your service in three sentences, with counts.
Check
Seam-tested service plus hidden seam tests — proven (T4):
seat_fileseat_testseat_lint
Session 6 · Performance: measure first
Six steps on making things fast without guessing. Why debug and release builds differ by ten times and which one you time. std::time::Instant as the benchmark harness, with warm-up, repetitions and the median, because criterion is not in the seat. Complexity you can see: a Vec scan inside a loop turning quadratic, and the HashMap or sort that fixes it. Allocation: String and Vec growth, clone in a hot loop, with_capacity, and borrowing instead of owning. Iterators versus index loops in release mode. Reading the output of a hand-rolled profiler: a Timer guard per phase of a batch. Live in the seat: a 100,000-row report timed in debug and release, a quadratic duplicate check made linear, a hot loop with a clone removed, and the measured hot path lab.
Lab The measured hot path in rust/rs300/perf: a report over 200,000 generated rows with a Timer guard per phase, a baseline recorded in BENCH.md, two measured improvements (an O(n²) check made O(n), an allocation removed), release-mode timings before and after, and a test proving the output is byte-identical to the slow version. Session 6 checkpoint.
6 mini steps · ~53 min
6.1Debug versus release; what to time8 minchecked · T3
Predict
A loop summing 10 million i64 takes 80 ms in cargo run and 8 ms in cargo run --release. Which number goes in the report?
Lesson
cargo build makes a debug binary: no optimisation, overflow checks, debug info. cargo build --release optimises; it is what ships and what you time. Time the whole operation first (end to end), then phases. Never compare a debug number with a release one, and record which you used.
Demo
The tutor generates 200,000 order rows into rust/rs300/demo/perf/samples/big.csv, runs the report in debug and release timing both with time in the terminal, and writes both numbers with the build mode into BENCH.md.
Hands-on
Create rust/rs300/perf with a bin that reads samples/big.csv (200,000 rows: id,customer,cents) and prints the total and the row count. Time cargo run and cargo run --release; write BENCH.md with a table: step, build, ms. Print "Rows 200000" and the total.
Explain
Why is comparing a debug time with a release time meaningless?
Check
Report runs; BENCH.md records debug and release — checked (T3):
seat_fileseat_run6.2Instant, warm-up, repetitions, median9 minchecked · T3
Predict
Five timings of the same phase: 12, 11, 48, 12, 11 ms. Which single number describes it best?
Lesson
A benchmark harness in twenty lines: fn bench(name, reps, f) runs f once to warm caches, then reps times with Instant::now() and elapsed(), sorts the durations and prints the median and the min. Use std::hint::black_box so the optimiser cannot delete the work. Same input, same machine, release mode.
Demo
The tutor writes src/bench.rs with bench() and black_box, times the report's parse phase 7 times, prints median and min, then runs it twice to show the numbers are stable to a few percent.
Hands-on
Write src/bench.rs with pub fn bench<F: FnMut() -> R, R>(name: &str, reps: usize, mut f: F) -> Duration (warm-up, reps runs, median) using Instant and black_box. Add a bench subcommand that times parse and total phases 7 times each and prints "<phase> median <ms> ms min <ms> ms".
Explain
What is black_box for?
Check
A bench harness with warm-up and median — checked (T3):
seat_fileseat_run6.3Seeing the quadratic9 minchecked · T3
Predict
Duplicate-id detection with for each row { if seen_vec.contains(&id) } over 200,000 rows does roughly how many comparisons?
Lesson
A linear operation inside a linear loop is quadratic. The tell is a Vec::contains, a nested loop over the same data, or a remove(0). The fix is a HashSet (O(1) membership), a HashMap, or sorting once and scanning. Measure before and after; the speed-up at 200,000 rows is the kind that turns a timeout into a blink.
Demo
The tutor adds a duplicate-id check with Vec::contains to the demo report, times it at 20,000 and 200,000 rows to show the 100× jump, replaces it with a HashSet, and times again.
Hands-on
Add a duplicates phase: first with a Vec<u64> and contains (time it on samples/mid.csv, 20,000 rows, and note the trend), then with a HashSet<u64>. Keep both as fn dupes_slow and fn dupes_fast; print "Duplicates 17" for big.csv; add a test that both agree on mid.csv.
Explain
How did you recognise the quadratic before measuring it?
Check
dupes_slow and dupes_fast agree; HashSet in the fast path — checked (T3):
seat_fileseat_testseat_run6.4Allocation: clone, with_capacity, borrowing9 minchecked · T3
Predict
A hot loop does let name = row.customer.clone(); only to compare it with a &str. What does clippy or a profiler say?
Lesson
Allocation is the quiet cost: String::from, clone, format!, collect in a hot loop. Count them by reasoning, then remove: borrow (&str) instead of owning, Vec::with_capacity(n) when you know n, reuse a buffer across iterations, push_str into one String instead of format! per row. Measure each change; keep the ones that pay.
Demo
The tutor finds three allocations per row in the demo's report phase, removes them one at a time with the bench harness running after each, and records the medians in BENCH.md.
Hands-on
Find and remove at least two per-row allocations in your report phase (a clone, a format!, or a Vec without capacity), timing before and after with the bench subcommand in release. Record the before/after medians in BENCH.md under "Allocation".
Explain
Which removal paid most, and how do you know?
Check
Allocation removals measured and recorded — checked (T3):
seat_fileseat_fileseat_lint6.5Iterators versus loops in release; a Timer guard8 minchecked · T3
Predict
In release mode, a sum written as an iterator chain versus an index loop is usually?
Lesson
Measure the myth: iterator chains and index loops compile to the same thing in release, and the chain often loses the bounds checks. A Timer guard — struct that records Instant on creation and prints the phase name and elapsed on Drop — turns any block into a profiled phase with one line, and never forgets to stop.
Demo
The tutor benchmarks sum-by-index against sum-by-iterator at 200,000 rows in release, shows them within noise, then adds Timer guards around parse, dupes and report and reads the phase breakdown.
Hands-on
Add pub struct Timer with Drop printing "<phase> <ms> ms" to stderr, and wrap parse, dupes and report in guards. Add a bench comparing an index loop and an iterator chain for the total; record both medians in BENCH.md under "Iterators".
Explain
Why print the phase time in Drop rather than at the end of the block?
Check
Timer guard phases; iterator vs loop recorded — checked (T3):
seat_fileseat_fileseat_run6.6Lab: the measured hot path10 minchecked · T3
Predict
After optimising, how do you know the report is still correct?
Lesson
Everything from this session: a report over 200,000 rows with Timer phases, a baseline and each improvement recorded with build mode and medians in BENCH.md, the quadratic made linear, allocations removed, and a test that the fast report's output equals the slow report's output exactly. Faster and provably the same.
Demo
The tutor completes the demo, runs the equality test, shows BENCH.md with baseline and three improvements, and runs the final report in release reading the phase times.
Hands-on
Finish rust/rs300/perf: keep report_slow and report_fast; tests::fast_equals_slow_on_mid compares their full output Strings on samples/mid.csv; BENCH.md has Baseline, Duplicates, Allocation and Iterators sections with release medians; cargo run --release -- samples/big.csv prints Rows, Duplicates and Total.
Explain
State your biggest speed-up as a before/after and the single change that caused it.
Check
Measured, recorded, and provably identical output — checked (T3):
seat_testseat_fileseat_fileseat_runseat_lint
Session 7 · Reading, reviewing and the AI era
Six steps on code you did not write. Reading a module you have never seen: entry points, data flow, invariants, before changing a line. A review checklist for Rust services: ownership and lifetimes, error paths, unwrap in libraries, SQL parameters, bounds, concurrency hazards, tests that prove the claim. A supplied module with five planted defects, found by reading and confirmed by a failing test each. Verifying machine-written code: treat it as a pull request from a stranger, check the claims it makes, run clippy, write the test it did not. Writing a review that a colleague can act on. Live in the seat: a structure sketch of review_me.rs, the checklist applied line by line, a reproduction test per defect, a generated function whose comment lies, and the reviewed module lab.
Lab The reviewed module in rust/rs300/review: review_me.rs read and sketched, all five planted defects found with a failing test each, fixed with the tests passing, a written review in REVIEW.md with a finding per defect and a severity, and a generated function verified and corrected with its own test. Session 7 checkpoint.
6 mini steps · ~55 min
7.1Reading a module you did not write9 minchecked · T3
Predict
Before changing a line of review_me.rs, which do you write first?
Lesson
Read in three passes: entry points (pub fns and main), data flow (what comes in, what goes out, what is stored), and invariants (what must be true for this to work: sorted input, non-empty, unique ids). Write them down. Most defects are an invariant the author assumed and never checked.
Demo
The tutor copies rust/rs300/samples/s7/review_me.rs into rust/rs300/demo/review, reads it aloud in the three passes, and writes a SKETCH.md with the entry points, the data flow and six invariants.
Hands-on
Create rust/rs300/review as a lib; copy samples/s7/review_me.rs to src/lib.rs so it builds. Write SKETCH.md with three headings: Entry points, Data flow, Invariants (at least five). Do not change the code yet.
Explain
Name one invariant you found that the code never checks.
Check
Sketch with entry points, data flow and five invariants — checked (T3):
seat_fileseat_fileseat_build7.2The review checklist for a Rust service8 minchecked · T3
Predict
Which checklist item does a
.unwrap()on a parsed field inside a library function fail?Lesson
A checklist turns reading into finding: errors (unwrap/expect in libs, swallowed Err, wrong exit code), input (bounds, lengths, SQL parameters), numbers (integer overflow, f64 for money, off-by-one), concurrency (lock held across await, unbounded spawn), resources (files not closed on error), tests (does a test prove each claim?). Apply it line by line and write a finding per hit.
Demo
The tutor writes CHECKLIST.md with the six groups and about twenty questions, applies it to review_me.rs and marks each hit with the line number, finding five candidates.
Hands-on
Write CHECKLIST.md with the six groups (Errors, Input, Numbers, Concurrency, Resources, Tests) and at least three questions each. Apply it to src/lib.rs and append a Findings section listing each hit as "line N: group: one sentence".
Explain
Why a checklist rather than just reading carefully?
Check
Checklist with six groups and a findings list — checked (T3):
seat_file7.3Five planted defects, five failing tests10 minchecked · T3
Predict
You believe line 41 drops the last order of a batch. What do you do before fixing it?
Lesson
For each finding, write the smallest test that fails because of it: the off-by-one that drops the last order, the f64 total that is a cent out, the unwrap that panics on a short row, the duplicate id that is accepted, the negative quantity that passes. Red first. The test is the proof the review needs, and the fix comes after.
Demo
The tutor writes five tests in rust/rs300/demo/review/tests/defects.rs, one per finding, runs them to show five reds, then fixes the first defect and shows one green while four stay red.
Hands-on
Write tests/defects.rs with five tests, one per planted defect: last_order_is_kept, total_is_exact_cents, short_row_is_error_not_panic, duplicate_id_rejected, negative_qty_rejected. Run cargo test and record the five failures in CHECKLIST.md's Findings as "line N: ..." entries.
Explain
Which defect would have reached production silently, and why?
Check
Five reproduction tests exist and name the defects — checked (T3):
seat_fileseat_file7.4Fix, keep the tests, write the review9 minchecked · T3
Predict
A review finding says "this is wrong". A colleague reads it a week later. What is missing?
Lesson
Fix each defect with the smallest change that turns its test green and keep the tests. Then write REVIEW.md: one finding per defect with Location, Defect, Impact, Proof (the test name) and Severity (blocker, major, minor), plus a summary line. A review is a document for the next reader as much as for the author.
Demo
The tutor fixes the remaining four defects in the demo one by one, each turning its test green, then writes REVIEW.md with five findings and reads one aloud in the five-field form.
Hands-on
Fix all five defects in src/lib.rs so tests/defects.rs is green with no other test broken. Write REVIEW.md with a Summary and five findings, each with Location, Defect, Impact, Proof and Severity fields.
Explain
Which finding did you mark blocker and why?
Check
Five defects fixed; review written in the five-field form — checked (T3):
seat_testseat_file7.5Verifying machine-written code9 minchecked · T3
Predict
A generated function's comment says "handles leap years". What is the first thing you do?
Lesson
Treat generated code as a pull request from someone you have never met: read it with the checklist, run clippy, and for every claim in a comment or name write the test that would fail if the claim were false. Keep what passes; fix or discard what does not; never let a confident comment stand in for a test.
Demo
The tutor pastes rust/rs300/samples/s7/generated_due_date.rs — a function whose comment claims month-end and leap-year handling — into the demo, writes the four tests the comment implies, watches two fail, fixes the function with chrono and keeps the tests.
Hands-on
Copy samples/s7/generated_due_date.rs into src/due.rs and declare the module. Write tests/due.rs with month_end_rolls_correctly, leap_day_handled, thirty_day_terms_from_jan_31 and negative_terms_rejected. Fix due_date until all pass; clippy clean.
Explain
What did the generated code claim that was not true, and how did you find out?
Check
Generated due_date verified by four tests and fixed — checked (T3):
seat_fileseat_testseat_lint7.6Lab: the reviewed module10 minchecked · T3
Predict
After fixing, the original author asks what changed. The best answer is?
Lesson
Everything from this session as a finished review: SKETCH.md, CHECKLIST.md with findings, five reproduction tests now green, REVIEW.md in the five-field form, the generated function verified and fixed with its tests, clippy clean. The code is better and, more importantly, the reasons are written down.
Demo
The tutor runs the whole demo review suite, reads REVIEW.md's summary line, and shows the before/after diff of review_me.rs alongside the five tests that pin each fix.
Hands-on
Finish rust/rs300/review: all tests green (defects and due), SKETCH.md, CHECKLIST.md with findings, REVIEW.md complete with a Summary that states the number of findings by severity. Clippy clean. Press Check.
Explain
In two sentences, how will you review the next piece of generated code you receive?
Check
Reviewed module: sketch, checklist, nine green tests, review document — checked (T3):
seat_testseat_fileseat_fileseat_lintcraft
Session 8 · Capstone: Orders
Six steps that build Orders, an order-processing service in production shape, from a specification. Reading the spec and designing the layers and the exit-code contract. The domain: order validation with reason codes, pricing with quantity breaks and a customer tier, money in integer cents. The pipeline: read a CSV of orders, validate each, price the good ones, write a posting report and a reject file with the original row and its reason code, log each phase. The contract: exit 0 when all orders post, 4 when some are rejected, 8 when the run cannot proceed. Tests at every layer, a README, clippy and rustfmt, and the grader's hidden order files and hidden tests. Live in the seat: the design, the walking skeleton that reads and writes empty files with exit 0, validation under test, the report over the published sample, and the final checklist.
Lab Orders: cargo run -- <orders.csv> <report.txt> <rejects.csv> validates, prices and reports, with exit 0/4/8 and the published outputs for samples/orders.csv matched exactly, hidden files (another batch, an all-rejects batch, an empty batch, a missing file, a malformed header) and hidden tests passing, README, clippy and rustfmt clean. Session 8 checkpoint and the course placement step.
6 mini steps · ~58 min
8.1Reading the spec, designing the layers9 minartefact · T2
Predict
An orders file has 10 rows, 2 rejected for bad quantity. Report written, rejects written. Exit code?
Lesson
Read rust/rs300/samples/s8/ORDERS-SPEC.md once, then design: domain (Order, Reject with ReasonCode, validate, price), app (process a batch into a Posting and a list of Rejects), adapters (CSV in, report and rejects out), main (args, logs, exit 0/4/8). Write the design as DESIGN.md with the module list and the public signatures the hidden tests will call.
Demo
The tutor reads the spec, sketches DESIGN.md for rust/rs300/demo/orders with four modules and six signatures, and creates the crate with todo!() bodies so it builds.
Hands-on
Read samples/s8/ORDERS-SPEC.md. Create rust/rs300/orders as a lib + bin. Write DESIGN.md with Modules, Public API and Exit codes sections, and src/lib.rs with the spec's public types and functions as todo!() bodies. cargo build clean.
Explain
Which layer owns the reason codes, and why not main?
Check
Design document and a building skeleton — artefact (T2):
seat_fileseat_fileseat_build8.2The walking skeleton9 minproven · T4
Predict
The skeleton reads the orders file and writes an empty report and an empty rejects file with exit 0, with zero business logic. Why build this first?
Lesson
Build the thinnest end-to-end path first: parse args, open the input (exit 8 with a message if it cannot be read or the header is wrong), write the report with headings and zero totals, write an empty rejects file with its header, log start and finish, exit 0. Nothing is validated or priced yet. Every later step grows this.
Demo
The tutor builds the demo skeleton, runs it on the sample and on a missing file, shows the two output files and the exit codes, and the two log lines.
Hands-on
Implement the skeleton in src/main.rs and src/adapters.rs: read the CSV header and require exactly id,customer,tier,sku,qty,unit_cents; write report.txt with a title line and "Posted 0 Rejected 0 Total 0.00"; write rejects.csv with header id,customer,tier,sku,qty,unit_cents,reason. Exit 8 on a missing file or bad header.
Explain
What does the skeleton prove, and what does it not?
Check
Skeleton: files written, exit 0; exit 8 on a missing file or bad header — proven (T4):
seat_runseat_fileseat_file8.3Validation with reason codes10 minchecked · T3
Predict
A row has qty 0 and an unknown tier. The spec says one reason per reject, first failing rule in order. Which reason?
Lesson
pub enum ReasonCode { BadQty, BadPrice, BadTier, DuplicateId, BadSku } with a fixed text code each. pub fn validate(row: &RawOrder, seen: &HashSet<u64>) -> Result<Order, ReasonCode> applies the rules in the spec's order: qty 1..=1000, unit_cents 0..=1_000_000, tier in {STD, GOLD, PLAT}, id unique, sku non-empty and at most 16 chars. Table tests from the spec's rule list.
Demo
The tutor writes validate in the demo domain with a table test of twelve rows straight from the spec's rule table, including the two-failures row that must report the first.
Hands-on
Implement ReasonCode with pub fn code(&self) -> &'static str and validate as specified. Tests: tests::validate_table with at least twelve rows including one with two failures reporting the first, plus tests::duplicate_id_second_row_rejected.
Explain
Why one reason per reject rather than all of them?
Check
ReasonCode and validate under a twelve-row table — checked (T3):
seat_fileseat_test8.4Pricing and the posting report10 minchecked · T3
Predict
GOLD tier gets 5% off after the quantity break of 10% from 10 units. 12 units at 300 cents: line total?
Lesson
pub fn price(order: &Order) -> i64 applies the quantity break then the tier discount, rounding to the cent after each, in integer arithmetic (× 90 / 100 with rounding). The report: a title, one line per posted order "<id> <customer> <qty> x <sku> <total>", then "Posted n Rejected m Total units.cents". Totals by iterator sum; order by id.
Demo
The tutor implements price with a table test from the spec's pricing examples, then the report writer, runs it over the sample and compares byte for byte with samples/s8/expected_report.txt.
Hands-on
Implement price with tests::price_table (eight rows from the spec), process_batch returning (Vec<Posted>, Vec<Reject>), and the report writer. cargo run -- samples/orders.csv target/report.txt target/rejects.csv produces a report equal to samples/s8/expected_report.txt.
Explain
Why round after each discount rather than once at the end?
Check
Pricing table green; report byte-equal to the published one — checked (T3):
seat_testseat_runseat_file8.5The reject file and the exit-code contract10 minproven · T4
Predict
The rejects file must hold the original row plus the reason. A rejected row had a quoted customer name with a comma. How is it written?
Lesson
rejects.csv carries every original column plus reason, written with csv::Writer so quoting survives. Exit codes from the batch result: all posted → 0; any rejected → 4; a run that cannot proceed (unreadable input, bad header, unwritable output) → 8 with one stderr line. Logs: batch.start, batch.done with posted, rejected, total and ms.
Demo
The tutor wires the reject writer and the exit mapping in the demo, runs the sample (exit 4), an all-good file (exit 0), an all-rejects file (exit 4) and an unwritable output path (exit 8), reading the logs each time.
Hands-on
Implement the reject writer and exit mapping. samples/orders.csv → exit 4 and target/rejects.csv equal to samples/expected_rejects.csv; samples/all_good.csv → exit 0 and an empty rejects file (header only); samples/all_bad.csv → exit 4 and an empty report body with Posted 0; output to /nonexistent/x → exit 8.
Explain
Why is "some rejected" a success code of 4 and not a failure of 8?
Check
Reject file byte-equal; exit codes 0/4/8 honoured — proven (T4):
seat_runseat_fileseat_fileseat_file8.6Lab: Orders proven10 minproven · T4
Predict
A hidden orders file has the header and no rows. Expected?
Lesson
The proof step. Your tests, the grader's hidden tests through validate, price and process_batch, and hidden runs: another batch with its own expected report and rejects, an all-rejects batch, an empty batch, a missing file, a malformed header. README, clippy and rustfmt complete the checklist. This is what the certificate attests.
Demo
The tutor runs the complete checklist on the demo Orders — fmt, clippy, test, the five hidden-style files — and reads the exit codes and the log lines, then presses Check and reads the grader's result together with the learner.
Hands-on
Finish rust/rs300/orders: README with What it is, Run, Test and Exit codes; all tests green; clippy and rustfmt clean; the published outputs matched. Press Check and read every line the grader returns.
Explain
State Orders' contract in three sentences a caller could rely on.
Check
Orders: tests, hidden tests, hidden batches, README, lint — proven (T4):
seat_testseat_lintseat_fileseat_runseat_fileseat_fileseat_fileseat_filecraft
Outcome
By the end of the course the tutor's checks will have verified: a clippy-clean, tested crate for every hands-on step; eight session labs that each grow the last — the layered quote service, the concurrent batch runner, the Postgres-backed repository, the hand-rolled HTTP API, the seam-tested service, the measured hot path, the reviewed module; and the Orders capstone, whose validation, pricing, report, reject file and exit codes 0/4/8 match the published expected values on inputs you did not see.
Certificate
Agile System Software Certificate — Rust, advanced (RS300) attests that you can architect, build, persist, serve, test, measure and review a production-shaped Rust service with an exit-code contract, verified by a machine from your own seat including hidden tests and hidden inputs.
To earn it:
- Pass the lab check of every session (steps 1.6, 2.6, 3.6, 4.6, 5.6, 6.6 and 7.6) on your own code.
- Pass the Orders capstone check (8.6) including the hidden input files and hidden tests the grader supplies.
- Score 80% or better on the 30-question reading quiz.
Gates (step · tier required):
Plus a 30-question reading quiz at 80%, retake after 48 h. Certificates are issued once the seat check runner is live; until then every step records what the engine saw.
Environment
A VS Code seat in your browser with Rust 1.99, cargo, clippy, rustfmt, rust-analyzer and rust-gdb, the offline crate set (tokio, postgres, serde, serde_json, clap, thiserror, anyhow, chrono, rust_decimal, rand, regex, csv, itertools) and PostgreSQL 17 running inside the seat; nothing to install. Your work lives in rust/rs300; the tutor's demo files live apart from yours and the tutor never edits your hands-on file while you are working on it.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick RS300 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.