Skip to content

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.

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.

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 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.

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.

Terminal window
cargo run --bin snip
# in another terminal:
curl localhost:8080/health
# → ok

If 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.

  1. What does #[tokio::main] do, and why can’t an async fn main run without something like it?
  2. Why is metrics wrapped in Arc while db and cache are not?
  3. What is the difference between returning &'static str and impl IntoResponse from a handler?
  4. In axum 0.8, how do you declare a path parameter, and what changed from earlier versions?
  5. 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
  1. #[tokio::main] is a macro that wraps your async fn main in Tokio’s runtime — the thing that actually drives every .await point. Rust has no built-in async runtime, so without it an async fn would just return a future that never runs.
  2. metrics is wrapped in Arc because all requests must increment the same counters, and Arc (atomic reference count) lets many owners share one value. db and cache (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 extra Arc.
  3. &'static str is a concrete type: axum makes a 200 OK with that string as the body. impl IntoResponse says “some type that can become a response, I won’t name it” — which lets health return a (StatusCode, &str) tuple (axum reads the first element as the status, the second as the body). Both work because the returned types implement the IntoResponse trait.
  4. 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 :code syntax won’t compile against this version.
  5. They come from environment variables (DATABASE_URL, REDIS_URL, BIND_ADDR, BASE_URL), each with a sensible local default via unwrap_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.