The MVP
Every scalable system starts as a single process that does almost nothing. That’s deliberate. If we
bolt on caching, replicas, and queues before there’s a thing to scale, we’ll never know which change
actually helped. So this chapter builds the skeleton of Snip: a web server that boots, listens on a
port, and answers two trivial routes — / and /health. No database, no Redis, no cleverness.
By the end you’ll understand the shape of an axum service — the
Router, handlers, and shared state — and you’ll have a binary you can curl. The two
interesting routes, /shorten and /{code}, are deliberately left as forward references; we wire up
their storage in the next chapter.
The imports, and why each is here
Section titled “The imports, and why each is here”Rust makes dependencies explicit. Every type and trait you use has to be brought into scope with use.
Reading the import block top to bottom is a good map of what the program does.
use std::env;use std::sync::atomic::{AtomicU64, Ordering};use std::sync::Arc;
use axum::extract::{Path, Request, State};use axum::http::{HeaderMap, StatusCode};use axum::middleware::{self, Next};use axum::response::{IntoResponse, Redirect, Response};use axum::routing::{get, post};use axum::{Json, Router};use redis::AsyncCommands;use serde::{Deserialize, Serialize};use sqlx::postgres::PgPoolOptions;use sqlx::PgPool;use tracing_subscriber::EnvFilter;The std::sync items (Arc, AtomicU64, Ordering) are about sharing data safely across many
concurrent requests — we’ll meet them in the next section. The axum::* lines are the web framework:
Router maps paths to functions, get/post declare which HTTP method a route accepts, and the
extract and response modules are how data flows into and out of your handlers. redis, sqlx,
and serde are the cache, database, and JSON layers (used later); tracing_subscriber sets up logging.
Shared state: what every request can see
Section titled “Shared state: what every request can see”A web server handles many requests at once, often on different threads. They frequently need the same things — a database connection pool, a Redis handle, some counters. Rather than make those globals, axum gives you a state value that it clones into every handler. Here’s Snip’s:
#[derive(Clone)]struct AppState { db: PgPool, cache: redis::aio::ConnectionManager, base_url: String, metrics: Arc<Metrics>,}AppState is just a struct holding handles, not data. PgPool is internally a pool of connections,
and ConnectionManager is a shared handle to one multiplexed Redis connection — cloning either is cheap
(it bumps a reference count, it does not open a new connection). That’s why #[derive(Clone)] is safe and
fast here: axum clones AppState per request, but each clone shares the same underlying pool and connection.
The metrics field is wrapped in Arc — an Atomically Reference-Counted pointer. Arc<T>
lets many owners share one T; the value is dropped only when the last owner goes away. We need it
because all requests must increment the same counters, not their own copies.
#[derive(Default)]struct Metrics { shortens: AtomicU64, redirects: AtomicU64, cache_hits: AtomicU64, cache_misses: AtomicU64, not_found: AtomicU64,}Each counter is an AtomicU64 — an unsigned 64-bit integer you can increment from multiple threads
without a lock and without data races. #[derive(Default)] lets us create a fresh Metrics with all
counters at zero by calling Metrics::default(). We’ll expose these numbers over /metrics later;
for now just note that state holds shared handles and shared counters.
The main function
Section titled “The main function”The entry point is marked #[tokio::main]:
#[tokio::main]async fn main() { tracing_subscriber::fmt() .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) .init();Rust has no built-in async runtime, so #[tokio::main] is a macro that wraps your async fn main in
Tokio’s runtime — the thing that actually drives all the .await points. Without
it, an async fn would just return a future that never runs. The first statements configure logging:
read the RUST_LOG env var if set, otherwise default to the info level.
Next, configuration. Twelve-factor style, everything comes from the environment with a sensible local default:
let database_url = env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://snip:snip@localhost:5432/snip".to_string()); let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string()); let bind_addr = env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); let base_url = env::var("BASE_URL").unwrap_or_else(|_| format!("http://{bind_addr}"));env::var returns a Result — present or missing — and unwrap_or_else supplies the fallback when
it’s missing. This is why the same binary runs unchanged on your laptop and in Docker: only the env vars
differ. The next lines connect to Postgres and Redis — we’ll dissect those in the next chapter — and
then build the shared state. The interesting part for now is wiring up routes:
let app = Router::new() .route("/", get(root)) .route("/shorten", post(shorten)) .route("/{code}", get(redirect)) .route("/health", get(health)) .route("/metrics", get(metrics_handler)) .layer(middleware::from_fn_with_state(state.clone(), rate_limit)) .with_state(state);A Router is a table of path → handler. Each .route() pairs a URL path with a method filter:
get(root) means “on GET requests to this path, call the root function.” post(shorten) means the
same for POST. The .layer(...) line attaches middleware (rate limiting, covered in
its own chapter), and .with_state(state) makes our AppState
available to every handler.
:::caution axum 0.8 path syntax
The redirect route is "/{code}", not "/:code". axum 0.8 switched to curly-brace path parameters.
If you’re following an older tutorial that uses :code, it won’t compile against this version.
:::
Finally, bind a TCP socket and serve:
let listener = tokio::net::TcpListener::bind(&bind_addr) .await .expect("bind address"); tracing::info!("Snip listening on {bind_addr}"); axum::serve(listener, app).await.expect("server");}TcpListener::bind opens the port; axum::serve(listener, app) runs the accept loop forever, handing
each connection to the router. Both are async, so we .await them. .expect(...) says “if this fails,
crash with this message” — fine for startup, where there’s nothing to recover to.
The first two handlers
Section titled “The first two handlers”A handler is just an async fn. axum inspects its return type and turns it into an HTTP response.
async fn root() -> &'static str { "Snip — POST /shorten {\"url\":\"...\"} • GET /{code}"}root takes no arguments and returns a string slice. axum knows how to make a 200 OK with that text
as the body, because &str implements the IntoResponse trait — the contract for “anything that can
become a response.”
async fn health() -> impl IntoResponse { (StatusCode::OK, "ok")}health returns impl IntoResponse, meaning “some type that can become a response, I won’t name it.”
A tuple of (StatusCode, &str) is one such type: axum reads the first element as the status code and the
second as the body. Health checks like this are what a load balancer or Docker hits to ask “are you
alive?” — we’ll lean on /health heavily once we run multiple replicas.
That’s the whole skeleton: state, a router, an accept loop, and two handlers. The shorten and
redirect handlers are listed in the router but their bodies talk to Postgres and Redis, so we build
them in the next two chapters.
Try it
Section titled “Try it”cargo run --bin snip# in another terminal:curl localhost:8080/health# → okIf you don’t yet have Postgres and Redis running, startup will fail at the connect to postgres (or
connect to redis) step — that’s expected, and the next chapter
brings both up in Docker.
Check your understanding
Section titled “Check your understanding”- What does
#[tokio::main]do, and why can’t anasync fn mainrun without something like it? - Why is
metricswrapped inArcwhiledbandcacheare not? - What is the difference between returning
&'static strandimpl IntoResponsefrom a handler? - In axum 0.8, how do you declare a path parameter, and what changed from earlier versions?
- Where do the configuration values (database URL, bind address) come from, and what makes the same binary runnable both locally and in a container?
Show answers
#[tokio::main]is a macro that wraps yourasync fn mainin Tokio’s runtime — the thing that actually drives every.awaitpoint. Rust has no built-in async runtime, so without it anasync fnwould just return a future that never runs.metricsis wrapped inArcbecause all requests must increment the same counters, andArc(atomic reference count) lets many owners share one value.dbandcache(PgPool,ConnectionManager) are already shared handles — a connection pool and a multiplexed connection respectively — so cloning them is cheap and shares the same underlying resource, and they don’t need an extraArc.&'static stris a concrete type: axum makes a200 OKwith that string as the body.impl IntoResponsesays “some type that can become a response, I won’t name it” — which letshealthreturn a(StatusCode, &str)tuple (axum reads the first element as the status, the second as the body). Both work because the returned types implement theIntoResponsetrait.- In axum 0.8 you write a path parameter as
"/{code}"with curly braces. Earlier versions used the colon form"/:code", so following an older tutorial’s:codesyntax won’t compile against this version. - They come from environment variables (
DATABASE_URL,REDIS_URL,BIND_ADDR,BASE_URL), each with a sensible local default viaunwrap_or_else. That twelve-factor style is what makes the same binary run unchanged on your laptop and in Docker — only the env vars differ, not the code.