Capstone: A REST Service in Rust
Where this fits: this is module 38, the final module. Everything from
the applied half of the course — Postgres (20), REST/axum (21), state &
middleware (28), authentication (29), Redis (31), background tasks (32),
configuration (24), advanced errors (27), Docker (34), and the module system
(37) — composes here into one small but realistic service: the Rust
equivalent of the C# ASP.NET backend you have been using all along
(backend/RustCourse.Api).
What we are building
A JSON REST API for an items resource, backed by Postgres and fronted by a
Redis cache, with bearer-token auth on writes and a background worker:
GET /health -> { "status": "ok" } (liveness)
GET /items -> [ Item, ... ] (list, from Postgres)
GET /items/{id} -> Item (cache-aside via Redis)
POST /items -> 201 Item (requires Bearer token)
This service needs Postgres + Redis to RUN. The course only
build-verifies it — cargo build -p course_38_capstone is green, but we do
not start it (there is no database in the grader). See
Running it locally for the docker run commands. Because
there is no live database at build time, every SQL statement uses sqlx's
runtime query API (sqlx::query, query_as, query_scalar + .bind) and
not the compile-time-checked query! macros (those require a DATABASE_URL
while compiling).
Architecture: a multi-file crate
This is a single binary crate assembled from many files — a direct showcase
of module 37. main.rs is the crate root and the composition root; it declares
each sibling module and wires them together.
src/
main.rs // composition root: config -> pool -> redis -> state -> serve
config.rs // env-driven settings (module 24)
error.rs // AppError + IntoResponse (module 27)
state.rs // AppState (the shared "DI container") (module 28)
auth.rs // Bearer-token FromRequestParts extractor (module 29)
cache.rs // Redis cache-aside helpers (module 31)
worker.rs // background interval task + graceful shutdown (module 32)
routes.rs // router builder + `pub mod items;` (directory module, 37)
routes/
items.rs // the items handlers: SQL (20) + cache (31) + auth (29) + serde (25)
migrations/
001_init.sql // the `items` table
Dockerfile // multi-stage build (module 34)
In C#: the layout maps almost one-to-one onto backend/RustCourse.Api:
| This crate | C# backend |
|---|---|
main.rs | Program.cs (builder, middleware, endpoints, app.Run()) |
config.rs | IConfiguration / appsettings.json + env |
state.rs (AppState) | the DI container's registered singletons |
error.rs (AppError + IntoResponse) | exception middleware / IExceptionHandler + ProblemDetails |
auth.rs (FromRequestParts) | [Authorize] + the JWT bearer handler |
cache.rs | IDistributedCache (StackExchange.Redis) |
worker.rs | BackgroundService / IHostedService |
routes/items.rs | an ItemsController / minimal-API endpoint group |
migrations/001_init.sql | EF Core migrations (AppDbContext) |
Milestone-by-milestone walkthrough
The tasks file (tasks/TASKS.md) turns these into 8 build-it-yourself
milestones. Here is how the finished pieces fit together.
1. Skeleton & module tree (module 37)
main.rs opens with mod config; mod error; mod state; .... Remember from
module 37: mod foo; is not an import — it loads foo.rs into the module
tree. routes.rs additionally declares pub mod items;, which is the
directory-module form: the parent's code lives in routes.rs, the child in
routes/items.rs.
In C#: there is no mod step — the compiler discovers every .cs file
automatically. In Rust a file is invisible until some module declares it.
2. Configuration (module 24)
Config::from_env() reads DATABASE_URL, REDIS_URL, BIND_ADDR, API_TOKEN,
and a couple of tunables, each with a default, after pulling an optional .env
file into the environment with dotenvy.
In C#: builder.Configuration layering appsettings.json, environment
variables, and secrets, then GetConnectionString("Postgres") — exactly what
Program.cs does to decide between Postgres and the in-memory fallback.
3. The error type (module 27)
AppError is a thiserror enum with one variant per failure class. The crucial
trick is the #[from] attributes: sqlx::Error, redis::RedisError, and
serde_json::Error all convert into AppError automatically, so a handler can
write ? after a DB call, a cache call, or a JSON call and it just works. A
single impl IntoResponse for AppError then maps each variant to a status code
and a clean JSON body ({"error": "..."}), logging 5xx detail but never leaking
it to the client.
In C#: instead of throwing exceptions and catching them in a global handler,
Rust returns typed errors up the stack and converts them once, centrally —
the IntoResponse impl is your ProblemDetails middleware.
4. Shared state (module 28)
AppState { pool, redis, api_token, cache_ttl_secs } derives Clone and is
handed to the router via .with_state(...). Every handler receives a clone via
the State<AppState> extractor, so each field is cheap to clone: PgPool and the
Redis MultiplexedConnection are internally Arc-backed handles, and the token
is an Arc<str>.
In C#: AppState is the DI container. PgPool ≈ the pooled DbContext /
connection factory; the multiplexed Redis connection ≈ the singleton
IConnectionMultiplexer. Where C# injects services into a constructor, axum
injects state into a parameter.
5–6. The items routes + cache-aside (modules 20, 31, 25)
list_items reads straight from Postgres with query_as::<_, Item>. get_item
does cache-aside: check Redis (cache::get_json), and on a miss read Postgres
with fetch_optional (→ 404 via .ok_or(AppError::NotFound)?), then warm the
cache with a TTL. One Item struct does double duty — #[derive(FromRow)] maps
the snake_case DB columns, and #[serde(rename_all = "camelCase")] renames them
for JSON (price_cents → priceCents). created_at is stored as a Unix-epoch
BIGINT so the crate needs no date/time feature flag.
In C#: query_as is EF Core's LINQ/FromSqlRaw; cache-aside is
IDistributedCache.GetStringAsync/SetStringAsync; the dual-purpose Item is
your entity + DTO with [JsonPropertyName] (or a camelCase naming policy).
IAsyncEnumerable-ish note: axum/sqlx are async to the core. fetch_all
here returns a Vec, but sqlx also exposes .fetch(...) which yields a
Stream of rows — the streaming-results analogue of returning
IAsyncEnumerable<T> from a controller and await foreach-ing it.
7. Authentication (module 29)
auth.rs implements FromRequestParts<AppState> for AuthUser. A handler that
takes an AuthUser parameter will not run unless a valid
Authorization: Bearer <token> header is present — axum runs the extractor first
and short-circuits with our 401 AppError. create_item lists user: AuthUser,
so POST /items is protected; list_items/get_item don't, so reads are open.
axum 0.8 signature: FromRequestParts no longer uses #[async_trait].
Since Rust 1.75 the trait method is a real async fn (return-position
impl Future), so we write:
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> { /* ... */ }
}In C#: protection is declared by the handler's parameter list, not by an
[Authorize] attribute or middleware ordering — the type system enforces it.
8. Middleware, background worker & graceful shutdown (modules 28, 32)
main wraps the router in tower_http::trace::TraceLayer (request logging — a
Tower layer, axum's middleware). It spawns worker::run(...) with
tokio::spawn: an interval loop that periodically recomputes an item count and
caches it. Shutdown is cooperative: a tokio::sync::watch channel broadcasts a
flag; the worker select!s the interval tick against shutdown.changed(), and
the same signal future drives axum::serve(...).with_graceful_shutdown(...).
On Ctrl-C / SIGTERM the server stops accepting, drains in-flight requests, the
worker finishes its current tick, and main returns.
In C#: TraceLayer ≈ app.UseHttpLogging(); the worker ≈ a
BackgroundService with a PeriodicTimer; the watch::Receiver ≈ the
CancellationToken from StopAsync / IHostApplicationLifetime.ApplicationStopping.
tokio::select! is await Task.WhenAny(tick, cancelled).
IDisposable/scope note: Rust has no using/IDisposable. Cleanup is
RAII — when pool and the Redis connection are dropped at the end of main,
their connections close. Graceful shutdown is about ordering that teardown
(drain requests, stop the worker) before those drops happen.
Running it locally
The crate builds without a database, but to actually run it you need Postgres and Redis. With Docker:
# 1. Start dependencies
docker run -d --name pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16
docker run -d --name redis -p 6379:6379 redis:7
# 2. Create the schema
psql "postgres://postgres:postgres@localhost:5432/postgres" -f 38_capstone/migrations/001_init.sql
# 3. Run the service (from the workspace root, course/)
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres"
export REDIS_URL="redis://127.0.0.1/"
export API_TOKEN="dev-secret-token"
cargo run -p course_38_capstone
# 4. Exercise it
curl http://127.0.0.1:3000/health
curl http://127.0.0.1:3000/items
curl http://127.0.0.1:3000/items/1
curl -X POST http://127.0.0.1:3000/items \
-H "Authorization: Bearer dev-secret-token" \
-H "Content-Type: application/json" \
-d '{"name":"Notebook","description":"dotted A5","priceCents":850}'
# Missing/invalid token -> 401:
curl -X POST http://127.0.0.1:3000/items -d '{"name":"x"}'Or run the whole thing in a container (build context is the workspace root):
cd course
docker build -f 38_capstone/Dockerfile -t capstone .
docker run --rm -p 3000:3000 \
-e DATABASE_URL="postgres://postgres:postgres@host.docker.internal:5432/postgres" \
-e REDIS_URL="redis://host.docker.internal:6379/" \
-e API_TOKEN="dev-secret-token" \
capstoneSQL schema
migrations/001_init.sql (note the deliberately feature-free column types):
CREATE TABLE IF NOT EXISTS items (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price_cents BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM now())::bigint)
);Key takeaway
A real service is composition: each module you learned is one slice — config,
errors, state, auth, data, cache, middleware, background work — and the capstone
is the wiring that makes them one program. Two ideas carry the whole design.
First, one error type with #[from] + IntoResponse turns the polyglot of
failure types (sqlx, redis, serde) into a single Result<T, AppError> that maps
cleanly to HTTP — the Rust replacement for exception middleware. Second,
explicit wiring in a composition root: main.rs builds the pool and the Redis
connection, packs them into a Clone AppState, hands that to the router, spawns
the worker, and serves with graceful shutdown — the same responsibilities as C#'s
Program.cs, but with dependencies passed by value instead of resolved from a
container, and teardown handled by RAII instead of IDisposable. Build it,
read the compiler's errors as a guide, and you have shipped a Rust backend.