# Mrsheerluck Blog > Personal notes on engineering, systems, and software. Index: https://blog.sheerluck.dev/llms.txt # Mrsheerluck Blog > Personal notes on engineering, systems, and software. Source: https://blog.sheerluck.dev/ · Markdown: https://blog.sheerluck.dev/index.md Hi, Thank you so much for visiting my blog. I write mostly on whatever I am learning and want to share my knowledge on. The topic does not always have to be based on tech. It can be science, maths, economics, philosophy, game design, animation, and more. My point is this is my digital garden, and I am thanking you for being a part of this journey. Browse all posts via the sidebar, or subscribe via [RSS](/rss.xml). If you have any feedback or want to request any topic, please feel free to reach out. - X: [@sheerluck_io](https://x.com/sheerluck_io) - Bluesky: [@mrsheerluck.bsky.social](https://bsky.app/profile/mrsheerluck.bsky.social) - GitHub: [MrSheerluck](https://github.com/MrSheerluck) - YouTube: [@sheerluck-dev](https://www.youtube.com/@sheerluck-dev) # Learn Axum Basics and Routing by Building a URL Shortener > In this post, we are going to learn baics of Axum and routing and build a URL shortener in Axum. This is the first part of our backend series in Rust Source: https://blog.sheerluck.dev/posts/axum/learn-axum-basics-and-routing-by-building-a-url-shortener/ · Markdown: https://blog.sheerluck.dev/posts/axum/learn-axum-basics-and-routing-by-building-a-url-shortener/index.md Welcome to the backend engineering series. If you followed the learning-rust series, you now understand ownership, borrowing, structs, enums, error handling, generics, lifetimes, closures, smart pointers, concurrency, and async/await. You built a thread pool and a raw HTTP/1.1 server from scratch over TCP. That was the foundation. Now we move up a layer. ![Axum Basics Cover Image](/images/axum-basics-cover.png) In this series, we are going to learn backend engineering as practiced in Rust today, routing, persistence, authentication, caching, middleware, background jobs, WebSockets, API contracts, security hardening, testing, and deployment. Every article introduces a concept and then builds a standalone project around it. No shared codebase across articles. No capstone. Just a deliberate sequence where each concept is easiest to absorb once the previous one is in place. The only prerequisite is that you have completed the learning-rust series or are comfortable with async Rust and Tokio. I will assume you know how `Arc`, `tokio::spawn`, and `.await` work. In this post, we are going to **build a URL shortener with Axum**. But before that, we will learn what Axum actually is, how a request flows through it, from the TCP socket all the way to your handler function and why it's designed the way it is. We will learn about `Router`, `route()`, the `Handler` trait, path parameters, `State`, and the `IntoResponse` trait. I promise you by the end of this article, Axum won't feel like a black box. Let's start, I can't wait. Get the source code from [here](https://github.com/MrSheerluck/url-shortener-in-axum) ## From Raw TCP to Axum In the async/Tokio article, we built an HTTP server by hand. We called `TcpListener::bind`, accepted connections in a loop, read raw bytes from each socket, split the bytes on `\r\n` to find the request line, parsed headers manually, matched on the HTTP method, and wrote raw response bytes back with `socket.write_all`. It worked. It taught us what HTTP actually is under the hood. But that approach has a problem: every new endpoint means more manual byte-parsing logic. Every new feature like path parameters, query strings, JSON bodies, middleware is something you have to build yourself. And the result, even for a simple server, is hundreds of lines of infrastructure code before you write a single line of actual business logic. Axum solves exactly this. It sits on top of `hyper` (Rust's low-level HTTP library) and `tower` (a middleware and service abstraction), and gives you a clean set of abstractions: routes, extractors, responses, and state. You write a function. Axum calls it when a matching request arrives. You return something. Axum converts it into an HTTP response using the `IntoResponse` trait and writes it to the socket. Everything in between like parsing headers, routing, serialization, error conversion is handled by the framework. Here is the mental model to hold onto: Axum is a layer cake. ``` Your handler function | Axum (Router, extractors, IntoResponse) | Tower (Service, Layer middleware) | Hyper (HTTP/1.1 and HTTP/2 protocol handling) | Tokio (async runtime, TCP sockets, I/O multiplexing) ``` Every request that arrives on a TCP socket bubbles up through Tokio (which handles the async I/O), through Hyper (which parses the raw bytes into an HTTP request representation), through Tower (which runs middleware), through Axum's routing layer (which matches the path and method to your handler), and finally into your function. The response travels back down the same stack in reverse. If you built the raw HTTP/1.1 server from the async article, you have already built the bottom two layers by hand. Axum gives you the top three. ### What is Hyper? Hyper is a low-level HTTP implementation. It parses the raw bytes received over a TCP connection into HTTP request values, manages connection lifecycles, implements HTTP/1.1 and HTTP/2, handles features like chunked transfer encoding, and encodes HTTP responses into bytes before writing them to the socket. Axum builds on top of Hyper rather than talking to TCP sockets directly. ### What is Tower? Tower provides the `Service` trait: ```rust pub trait Service { type Response; type Error; type Future: Future>; fn poll_ready(&self, cx: &mut Context<'_>) -> Poll>; fn call(&self, req: Request) -> Self::Future; } ``` A `Service` is anything that takes a request and returns a future that resolves to a response. That is it. An Axum `Router` implements `Service`. A rate-limiting middleware is a `Service` that wraps another `Service`. Everything in the request pipeline is eventually represented as a Tower `Service`: routers implement `Service`, middleware wraps `Service`s, and Axum adapts your handler functions into `Service`s behind the scenes. The `poll_ready` method lets a service indicate whether it is currently ready to receive another request. Tower uses this for backpressure. Most application code never interacts with it directly, so we'll ignore it in this series. A `Layer` produces a new `Service` by wrapping an existing `Service`. When you call `.layer(some_middleware)` on a router, you are wrapping the router's `Service` inside the middleware's `Service`. Every request passes through the outer layer first, then the inner layer, then eventually reaches your handler. We will go deep on Tower and middleware in Part 5. For now, just know it's there. ## Project Setup Create a new project: ``` cargo new url_shortener cd url_shortener ``` Open `Cargo.toml` and add the dependencies: ```toml [package] name = "url_shortener" version = "0.1.0" edition = "2024" [dependencies] axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } ``` We have four dependencies: - `axum` is the web framework - `tokio` is the async runtime (Axum runs on top of it) - `serde` and `serde_json` handle JSON serialization and deserialization - `uuid` generates unique short codes for our URLs. The `v4` feature gives us random UUIDs ## The Simplest Possible Axum Server Open `src/main.rs`, delete everything, and write this: ```rust use axum::{routing::get, Router}; use tokio::net::TcpListener; #[tokio::main] async fn main() { let app = Router::new().route("/", get(root)); let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); } async fn root() -> &'static str { "Hello, Axum!" } ``` Now, let me explain what we just did. `Router::new()` creates an empty router. `.route("/", get(root))` registers a route: when a `GET` request arrives at `/`, call the `root` function. `route()` takes a path and a method handler. `get(root)` is shorthand. Axum provides `get()`, `post()`, `put()`, `delete()`, `patch()`, `head()`, and `options()` for every HTTP method. Each of them wraps your function in the router's internal machinery. `TcpListener::bind("127.0.0.1:3000").await` creates a TCP listener bound to port 3000. This is the same `TcpListener` from Tokio that we used in the async article. You create the listener yourself and pass ownership of it to `axum::serve`. `axum::serve(listener, app)` is the entry point. It takes your listener and your router, and runs an event loop: accept connections from the listener, parse HTTP requests with Hyper, route them through your router, and write responses back. It runs until the process is killed or the listener is closed. The `root` function is a handler. It returns `&'static str`. Axum knows how to convert `&'static str` into an HTTP response because `&str` implements the `IntoResponse` trait. Run it: ``` cargo run ``` Visit `http://127.0.0.1:3000` in your browser or run `curl http://localhost:3000`. You should see `Hello, Axum!`. ## The Handler Trait and async fn Why does `async fn root() -> &'static str` work as a handler? Because Axum implements the `Handler` trait for async functions that satisfy certain conditions. The implementation is generic over the function's arguments and return type. You do not implement `Handler` yourself. The framework does it for you through a blanket impl. The rule is simple: any async function whose arguments all implement `FromRequestParts` (or `FromRequest` for the body) and whose return type implements `IntoResponse` is a valid handler. Extractors implementing `FromRequest` consume the request body, so a handler can have only one body extractor. Extractors implementing `FromRequestParts` only inspect the request metadata (such as the path, headers, or state), so they can be freely combined. This is what the `Handler` trait looks like, simplified: ```rust pub trait Handler: Clone + Send + Sized + 'static { type Future: Future + Send + 'static; fn call(self, req: Request, state: S) -> Self::Future; } ``` When Axum matches a route to your function, it calls `Handler::call`. The function's arguments are extracted from the request. The function runs. The return value is converted into an `http::Response` via `IntoResponse`. The response is written back to the socket. You never see this machinery. You just write a function and Axum does the rest. ## IntoResponse - Why So Many Types Work In the raw HTTP server, we built response strings by hand: `format!("HTTP/1.1 200 OK\r\nContent-Length: ...\r\n\r\n{}", body)`. That was tedious and error-prone. Axum solves this with the `IntoResponse` trait: ```rust pub trait IntoResponse { fn into_response(self) -> Response; } ``` Anything that implements this trait can be returned from a handler. Axum provides implementations for: | Type | What it produces | |------|-----------------| | `&'static str` | 200 OK with `text/plain; charset=utf-8` | | `String` | 200 OK with `text/plain; charset=utf-8` | | `StatusCode` | A response with just that status code, no body | | `(StatusCode, T)` | A response with that status code and body `T` (where `T: IntoResponse`) | | `(HeaderMap, T)` | A response with custom headers and body `T` | | `(StatusCode, HeaderMap, T)` | Status code, custom headers, and body | | `Json` | 200 OK with `application/json` body (where `T: Serialize`) | | `Html` | 200 OK with `text/html; charset=utf-8` body | The tuple implementations are important. They let you compose status codes and bodies without a single wrapper type: ```rust async fn handler() -> (StatusCode, &'static str) { (StatusCode::NOT_FOUND, "nothing here") } ``` This returns a 404 with a plain-text body. The tuple `(StatusCode, T)` implements `IntoResponse` by combining the status code from the first element with the body from the second. The body's `IntoResponse` implementation handles the `Content-Type` header and the actual bytes. `Json` is another key type. It wraps any `T: Serialize` and returns a 200 with `Content-Type: application/json`. If serialization fails, it returns a 500 Internal Server Error. ## State - Sharing Data Across Handlers In the raw HTTP server, we used `Arc` to share the serve directory across all connection tasks. Axum has the same need, your handlers often need access to shared data like a database pool, a configuration struct, or, in our case, the map of shortened URLs. Axum provides `State` for this: ```rust use axum::extract::State; use std::sync::Arc; #[derive(Clone)] struct AppState { message: String, } async fn handler(State(state): State>) -> String { state.message.clone() } #[tokio::main] async fn main() { let state = Arc::new(AppState { message: "hello from state".to_string(), }); let app = Router::new() .route("/", get(handler)) .with_state(state); let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap(); axum::serve(listener, app).await.unwrap(); } ``` Now, let me explain what we just did. `State>` is an extractor. When Axum calls your handler, it looks at the function's arguments and tries to extract each one from the request (or from the application state). `State` extracts the shared state that you provided to the router via `.with_state()`. `Arc` is the actual state type. It implements `Clone`, and cloning an `Arc` is inexpensive because it only increments an atomic reference count. This makes it an ideal way to share immutable ownership of application state across all request handlers. `.with_state(state)` attaches the state to the router. Every handler in that router (and any sub-routers merged into it) can extract it with `State`. > **Important:** The state type must be the same for the entire router. If you need different pieces of state in different handlers, put them all in one struct and wrap it in `Arc`. ## Path Parameters In the raw HTTP server, we parsed paths manually, strip the leading `/`, check for `..`, join with the serve directory. For a URL shortener, we need something much simpler: extract a short code from the URL path like `/abc123`. Axum handles this with `Path`: ```rust use axum::extract::Path; async fn redirect(Path(code): Path) -> String { format!("You requested code: {}", code) } ``` When a request arrives at `GET /abc123`, Axum extracts `"abc123"` from the path, deserializes it into the type you specified (`String`), and passes it to your handler. `Path` implements `FromRequestParts`, just like `State`. The path parameter pattern in the route definition uses `{name}` syntax: ```rust let app = Router::new().route("/{code}", get(redirect)); ``` A request to `/abc123` matches this route, and the `Path(code)` extractor gives you `"abc123"`. > Axum uses the `{name}` syntax (not the deprecated `:name` syntax from older versions). If you see `:name` in old tutorials, replace it with `{name}`. ## The Project: URL Shortener Now that you understand `Router`, handlers, `State`, `Path`, and `IntoResponse`, let's build a URL shortener. Our program will: - Accept a `POST /shorten` with a JSON body containing a long URL - Generate a unique short code using UUID v4 - Store the mapping in memory - Return the short code as JSON - Accept a `GET /{code}` that looks up the code and issues a 302 redirect - Return 404 if the code does not exist To keep the focus on Axum itself, the application stores its data in memory using a `HashMap`. In later articles we will replace this with a real database using SQLx. ### What is a URL Shortener? A URL shortener takes a long URL like `https://en.wikipedia.org/wiki/Uniform_Resource_Locator` and maps it to a short one like `https://sho.rt/abc123`. When someone visits the short URL, the server looks up the original long URL and redirects them. The mapping is one-way: long URL → short code. There is no requirement to reverse-lookup a long URL to find its short code. This keeps things simple. ### The Full Code Replace everything in `src/main.rs` with this: ```rust use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Redirect}, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, RwLock}; use tokio::net::TcpListener; use uuid::Uuid; #[derive(Clone)] struct AppState { urls: Arc>>, } #[derive(Deserialize)] struct ShortenRequest { url: String, } #[derive(Serialize)] struct ShortenResponse { short_url: String, } #[tokio::main] async fn main() { let state = AppState { urls: Arc::new(RwLock::new(HashMap::new())), }; let app = Router::new() .route("/shorten", post(shorten)) .route("/{code}", get(redirect)) .with_state(state); let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); } async fn shorten( State(state): State, Json(payload): Json, ) -> impl IntoResponse { let code = &Uuid::new_v4().to_string()[..8]; state .urls .write() .unwrap() .insert(code.to_string(), payload.url); let short_url = format!("http://127.0.0.1:3000/{}", code); ( StatusCode::CREATED, Json(ShortenResponse { short_url }), ) } async fn redirect( State(state): State, Path(code): Path, ) -> impl IntoResponse { let urls = state.urls.read().unwrap(); match urls.get(&code) { Some(long_url) => Redirect::to(long_url).into_response(), None => ( StatusCode::NOT_FOUND, "404 Not Found: No URL for this code", ) .into_response(), } } ``` Now, let me explain what we just did. ### The State ```rust #[derive(Clone)] struct AppState { urls: Arc>>, } ``` `AppState` holds our in-memory store, a `HashMap` mapping short codes to long URLs. It is wrapped in `Arc>` because multiple requests will read and write to it concurrently. `RwLock` allows many concurrent readers or one exclusive writer, which matches our access pattern: reads are common (every redirect) and writes are rare (only on shortening). `#[derive(Clone)]` is necessary because Axum clones the state for internal use. Since `Arc` is cheap to clone, it only increments an atomic reference count, the derived `Clone` implementation simply creates another `Arc` pointing to the same `RwLock>`. This example uses `std::sync::RwLock` because each critical section is very small and we never `.await` while holding the lock. If you need to hold a lock across asynchronous work, prefer `tokio::sync::RwLock`. ### The Request and Response Types ```rust #[derive(Deserialize)] struct ShortenRequest { url: String, } #[derive(Serialize)] struct ShortenResponse { short_url: String, } ``` `ShortenRequest` is what the client sends in the POST body. `#[derive(Deserialize)]` from `serde` tells the compiler to generate code that can parse this struct from JSON. `ShortenResponse` is what we send back. `#[derive(Serialize)]` generates code that converts this struct into JSON. These types are used with the `Json` extractor and response type. When Axum sees `Json` as a handler argument, it reads the request body, parses the JSON, and if it matches the struct, passes it to the handler. If the body is missing, malformed, or has the wrong types, Axum returns a 422 Unprocessable Entity automatically, before your handler ever runs. ### The Shorten Handler ```rust async fn shorten( State(state): State, Json(payload): Json, ) -> impl IntoResponse { let code = &Uuid::new_v4().to_string()[..8]; state .urls .write() .unwrap() .insert(code.to_string(), payload.url); let short_url = format!("http://127.0.0.1:3000/{}", code); ( StatusCode::CREATED, Json(ShortenResponse { short_url }), ) } ``` The handler takes two extractors: `State` (the shared state) and `Json` (the parsed JSON body). Axum runs both extractors before calling the function. If either fails, the handler is never called and an error response is sent instead. `Uuid::new_v4()` generates a random UUID like `a1b2c3d4-e5f6-7890-abcd-ef1234567890`. We take the first 8 characters (`a1b2c3d4`) as our short code. Eight hexadecimal characters provide roughly 4.3 billion possible codes. For this toy project that is more than sufficient, although a production URL shortener would still need to detect and handle collisions before storing a newly generated code. `state.urls.write().unwrap()` acquires a write lock on the `RwLock`, giving us exclusive access to the `HashMap`. We insert the short code as the key and the long URL as the value. The lock is released when the `RwLockWriteGuard` goes out of scope at the end of the expression. The return type is `impl IntoResponse`. This means "I return something that implements `IntoResponse`, but I am not naming the exact type." The actual type is `(StatusCode, Json)`, which is a tuple. The tuple implementation sets the status code to 201 Created and the body to the JSON-serialized response. ### The Redirect Handler ```rust async fn redirect( State(state): State, Path(code): Path, ) -> impl IntoResponse { let urls = state.urls.read().unwrap(); match urls.get(&code) { Some(long_url) => Redirect::to(long_url).into_response(), None => ( StatusCode::NOT_FOUND, "404 Not Found: No URL for this code", ) .into_response(), } } ``` The `Path(code): Path` extractor pulls the `{code}` segment from the URL path. When a user visits `/abc12345`, `code` is `"abc12345"`. `state.urls.read().unwrap()` acquires a read lock. Multiple redirect requests can hold read locks simultaneously, they do not block each other. Only the `shorten` handler's write lock blocks readers. `urls.get(&code)` returns an `Option<&String>`. If the code exists, we get the long URL and return a `Redirect::to(long_url)`. `Redirect` is an Axum response type that produces a 303 Found with a `Location` header. The browser follows the redirect automatically. If the code does not exist, we return a 404 status with a plain-text body. Both branches call `.into_response()` explicitly. This is necessary because `match` arms must have the same type, and the two arms here produce different types (`Redirect` and `(StatusCode, &str)`). Calling `.into_response()` on each arm converts them both to `Response`, which is a single concrete type. > **Why `impl IntoResponse` and not `Response`?** When the return type is `impl IntoResponse`, Axum calls `.into_response()` for you after your handler runs. But when different branches of a `match` return different types, you need to call `.into_response()` inside each branch to unify them yourself. The `impl IntoResponse` in the signature still works because `Response` implements `IntoResponse`. ## Running the Project Start the server: ``` cargo run ``` You should see: ``` Listening on http://127.0.0.1:3000 ``` ### Shorten a URL Open another terminal: ``` curl -X POST http://localhost:3000/shorten \ -H "Content-Type: application/json" \ -d '{"url": "https://www.rust-lang.org"}' ``` Response: ```json {"short_url":"http://127.0.0.1:3000/a1b2c3d4"} ``` ### Follow the Redirect ``` curl -v http://localhost:3000/a1b2c3d4 ``` You should see: ``` * Request completely sent off < HTTP/1.1 303 See Other < location: https://www.rust-lang.org < content-length: 0 ``` Use `-L` to follow the redirect: ``` curl -L http://localhost:3000/a1b2c3d4 ``` This fetches the Rust home page. ### Test a Missing Code ``` curl -v http://localhost:3000/nonexist ``` Response: ``` < HTTP/1.1 404 Not Found 404 Not Found: No URL for this code ``` ### Test a Bad POST Body ``` curl -X POST http://localhost:3000/shorten \ -H "Content-Type: application/json" \ -d '{"not_url": 123}' ``` Response: ``` < HTTP/1.1 422 Unprocessable Entity ``` Axum rejected this before our handler ever ran because the JSON did not match `ShortenRequest`. The required field `url` was missing, so deserializing the request into `ShortenRequest` failed before the handler was called. This is the extractor at work, `Json` validates the body and returns a 422 if it does not match. ## How a Request Flows Through - The Full Trace Let's trace a single `GET /abc12345` request through every layer of the stack to understand what actually happens. 1. **Tokio**: A TCP packet arrives on port 3000. Tokio's I/O driver is notified that the listening socket is ready, the runtime schedules the appropriate async task, and that task reads the incoming bytes from the socket. 2. **Hyper**: Hyper parses the raw bytes into an HTTP request representation, extracting the method, URI, version, headers, and request body before passing it to the next layer. 3. **Tower**: The request enters the Tower service stack. If we had middleware (we do not in this project, but we will in Part 5), each `Layer` would get a chance to inspect or modify the request before passing it inward. The outermost layer runs first. 4. **Axum Router**: The router receives the request and efficiently matches the request path against the registered routes. The pattern `/{code}` matches `/abc12345`, captures the path segment, verifies the HTTP method is `GET`, and selects the `redirect` handler. 5. **Handler extraction**: Before calling `redirect`, Axum runs the extractors. `State` clones the `Arc` (cheap, just increments a reference count). `Path` deserializes `abc12345` into a `String`. Both succeed. The handler is called with these two arguments. 6. **Your handler**: `redirect` acquires a read lock, looks up the code in the `HashMap`, finds the long URL, and returns `Redirect::to(long_url).into_response()`. This produces an `http::Response` with status 302 and a `Location` header. 7. **Axum response**: The `Response` travels back through the router. Axum does nothing further, the response is complete. 8. **Tower**: The response travels back out through the middleware stack. Each layer gets a chance to inspect or modify the response. 9. **Hyper**: Hyper encodes HTTP responses into bytes before writing them to the socket: `HTTP/1.1 302 Found\r\nlocation: https://...\r\ncontent-length: 0\r\n\r\n`. It writes these bytes to the socket. 10. **Tokio**: The socket write is async. If the socket buffer is full, the task yields. When the buffer drains, the runtime wakes the task. The response finishes writing. The connection is closed (or reused, if keep-alive were enabled). All of this happens in under a millisecond for a simple in-memory lookup. The async runtime can handle thousands of these concurrently on a handful of OS threads. Compare this to the raw HTTP server from the async article. The flow is the same. The difference is that steps 3 through 7, routing, extraction, handler dispatch, response serialization were all written by hand in that article, and now Axum does them for us. ## What We Skipped There are a few things I am intentionally skipping in this article: - **Persistent storage**: Our URL mappings live in an in-memory `HashMap`. They disappear when the server restarts. In Part 3, we will add PostgreSQL persistence with SQLx. - **Request validation**: We accept any string as a URL. A real service would validate the format, reject empty strings, and potentially check that the URL is reachable. We will cover request validation in Part 2. - **Error handling**: We use `.unwrap()` on lock acquisition. In a production service, a poisoned lock (from a panic in another thread holding the lock) should be handled gracefully. We will build a proper `AppError` enum in Part 2. - **Custom short codes**: We use random UUID prefixes. A real service might let users choose custom codes or use a different encoding scheme (like base62) for shorter URLs. - **Tower middleware, logging, tracing, CORS**: These come in later parts, deliberately sequenced. - **Graceful shutdown**: The server stops immediately on Ctrl+C without draining in-flight requests. Part 9 covers this. > Everything we skipped in this article exists for a reason in real-world services. Rather than introducing that complexity all at once, we focused on the Axum request lifecycle, routing, state, path parameters, and response types. This is the foundation that every subsequent part builds on. ## Conclusion In this post, you learned what Axum is and where it sits in the stack: on top of Hyper and Tower, running inside Tokio. You learned the `Handler` trait, why any async function that takes extractors and returns `IntoResponse` just works as a handler. You learned `IntoResponse`, how tuples, `Json`, status codes, and `Redirect` compose into HTTP responses without any boilerplate. You learned `State` for shared application state and `Path` for path parameters. You built a URL shortener from scratch. The server accepts `POST /shorten` with a JSON body, generates a random short code, stores the mapping in an `Arc>`, and returns the short URL. `GET /{code}` looks up the code and issues a 302 redirect. Missing codes get a 404. Invalid JSON gets a 422. This is the foundation of every Axum application you will ever write. Router, extractors, state, and responses. Everything else like middleware, custom extractors, WebSockets, streaming is built on top of these four concepts. In the next article, we will learn about request bodies and error handling by building a Pastebin API. We will build a proper `AppError` enum, handle validation, and learn why Axum deliberately makes you decide every status code yourself instead of guessing. See you soon. If you like reading this, please subscribe and share this with others. It will really help me and motivate me to keep publishing more such articles. # Learn Axum Error handling by Building a Pastebin API > In this post, we are going to build a Pastebin API with proper error handling Source: https://blog.sheerluck.dev/posts/axum/learn-axum-error-handling-by-building-pastebin-api-in-rust/ · Markdown: https://blog.sheerluck.dev/posts/axum/learn-axum-error-handling-by-building-pastebin-api-in-rust/index.md In Part 1, we built a URL shortener and learned the Axum request lifecycle: `Router`, handlers, `State`, `Path`, and `IntoResponse`. But we left two things unfinished. Our error handling was `.unwrap()` on every lock acquisition, and our response for a missing URL was an ad-hoc `(StatusCode, &str)` tuple dropped directly into the handler. ![Axum Error Handling](/images/axum-error-handling.png) In this post, we are going to build a **Pastebin API with proper error handling**. We will learn the `Json` extractor in depth, the `Query` extractor for optional parameters, request validation, and most importantly how to build a single `AppError` enum that converts every possible failure into the right HTTP status code. Along the way, we will see why Axum deliberately has no hidden error-handling behavior, and why that is a feature, not a missing feature. Let's start, I can't wait. Get the source code from [here](https://github.com/MrSheerluck/pastebin-api-in-axum) ## The Problem with Ad-Hoc Error Handling In Part 1, when a short code was not found, we wrote: ```rust match urls.get(&code) { Some(long_url) => Redirect::to(long_url).into_response(), None => ( StatusCode::NOT_FOUND, "404 Not Found: No URL for this code", ) .into_response(), } ``` This works for one handler. Now imagine a real application with twenty handlers. Every handler that does a lookup has its own 404 logic. Every handler that deals with validation has its own 400 logic. The status codes and error messages are scattered across the codebase, and there is no single place to change how errors are formatted. The alternative that some frameworks choose is "hidden behavior": the framework catches your errors, guesses which HTTP status code to return, and sends a response for you. A missing database row becomes a 404. A failed deserialization becomes a 400. This sounds convenient, until it guesses wrong and your API returns a 500 for something that should be a 400, or worse, a 200 for something that should be a 500. In Axum, you define your own error type. You implement `IntoResponse` on it exactly once. You decide every status code. The framework does not make application-specific decisions for you. This is what we are going to build. ## The Json Extractor - Deeper Than You Think In Part 1, we used `Json` without much explanation. Now let's understand what it actually does. `Json` implements `FromRequest` for any `T: DeserializeOwned`. When a handler declares `Json(payload): Json`, Axum does three things: 1. **Buffers the entire request body.** The request body arrives as a stream of bytes from the client. `Json` first reads the entire stream into memory before attempting deserialization. This is why a handler can have at most one body extractor: once the body has been consumed, there is nothing left for another body extractor to read. 2. **Checks the Content-Type header.** If the request is not sent with a JSON media type (such as `application/json` or `application/*+json`), Axum rejects it with a `415 Unsupported Media Type`. This happens before deserialization, so the client gets a clear signal that it sent the wrong content type. 3. **Deserializes the JSON.** If the body contains valid JSON but it cannot be deserialized into your type (for example, because a required field is missing, a field has the wrong type, or `#[serde(deny_unknown_fields)]` rejects an unknown field), Axum rejects the request with an appropriate deserialization error, typically `422 Unprocessable Entity`. Invalid JSON syntax is rejected earlier with `400 Bad Request`. All three steps happen before your handler runs. If any step fails, the handler is never called. The request is rejected with the appropriate status code automatically. Your handler only runs when the body is present, correctly typed as JSON, and valid against your struct. This is the first half of Axum's explicit error-handling model. The framework handles malformed requests such as unsupported content types, invalid JSON syntax, and deserialization failures, so you do not have to. But semantic errors like empty content, invalid values, expired resources are yours to handle. The line is drawn at `serde` deserialization. Everything beyond that is your responsibility. ## The Query Extractor Some parameters are not part of the request body. They are part of the URL itself. Axum provides `Query` for this: ```rust use axum::extract::Query; use serde::Deserialize; #[derive(Deserialize)] struct ListParams { language: Option, page: Option, } async fn list_pastes(Query(params): Query) -> impl IntoResponse { // params.language is Some("rust") or None // params.page is Some(1) or None todo!() } ``` A request to `GET /pastes?language=rust&page=2` deserializes into `ListParams { language: Some("rust".into()), page: Some(2) }`. A request to `GET /pastes` with no query string gives `ListParams { language: None, page: None }`. Unlike `Json`, which deserializes a JSON request body, `Query` uses the `serde_urlencoded` crate together with Serde to deserialize URL query parameters into your type. The same `serde` infrastructure handles both. The difference is where the data comes from: `Json` reads the request body, `Query` reads the URL query string. > **When to use Query vs Json:** `Query` is most commonly used with `GET` requests (and sometimes `DELETE`). `Json` is most commonly used with `POST`, `PUT`, and `PATCH` requests. This is a widely followed REST convention rather than a rule enforced by HTTP, and some APIs legitimately use query parameters on other methods as well. ## Request Validation - Semantic Checks Deserialization checks structure. Validation checks meaning. `serde` can tell you that `content` is missing, but it cannot tell you that `content` is an empty string. It can tell you that `expires_in_seconds` is an integer, but it cannot tell you that `-5` is nonsensical for an expiry duration. In this project, we perform validation in the handler immediately after deserialization and before any business logic. Here is the pattern: ```rust fn validate_create_request(req: &CreatePasteRequest) -> Result<(), AppError> { if req.content.trim().is_empty() { return Err(AppError::ValidationError("content must not be empty".into())); } if req.content.len() > MAX_CONTENT_LENGTH { return Err(AppError::ValidationError( format!("content exceeds maximum length of {} bytes", MAX_CONTENT_LENGTH) )); } if let Some(ref lang) = req.language { if !SUPPORTED_LANGUAGES.contains(&lang.as_str()) { return Err(AppError::ValidationError( format!("unsupported language: {}", lang) )); } } if let Some(seconds) = req.expires_in_seconds { if seconds <= 0 { return Err(AppError::ValidationError( "expires_in_seconds must be positive".into() )); } } Ok(()) } ``` Every validation failure returns the same error variant: `AppError::ValidationError`. Every failure produces a 400 Bad Request. The difference is the message, which tells the client specifically what was wrong like is it empty content, too long, bad language, negative expiry. ## AppError - The Single Error Enum Now we arrive at the core pattern of this article. An `AppError` enum that: - Has one variant per category of error - Implements `IntoResponse` once, mapping each variant to the right status code - Has `From` impls so domain-level errors (like a failed lock acquisition) convert automatically ```rust enum AppError { ValidationError(String), NotFound(String), InternalError(String), } ``` Three variants and that's it. A real application might have more like `Unauthorized`, `Forbidden`, `Conflict` but three is enough to cover everything a simple API can encounter. ### ValidationError → 400 The client sent something wrong. The error is theirs to fix. Return a 400 with a message explaining what to fix. ### NotFound → 404 The requested resource does not exist. This covers both "never existed" and "existed but expired." From the client's perspective, an expired paste is the same as a paste that was never created: you cannot read it. ### InternalError → 500 Something went wrong on the server. The client cannot fix it. In a real application, you log the full error internally but send a generic message to the client. Leaking internal details like file paths, stack traces, database error strings is a security risk and a bad user experience. Now the `IntoResponse` implementation: ```rust impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.clone()), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), AppError::InternalError(msg) => { eprintln!("internal error: {}", msg); ( StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string(), ) } }; let body = Json(serde_json::json!({ "error": message, })); (status, body).into_response() } } ``` Every error response is a JSON object with an `"error"` field. Validation errors include the specific message. Not-found errors include the resource name. Internal errors log the actual error to stderr via `eprintln!` but send a generic message to the client, the client sees `{"error": "internal server error"}` regardless of whether the database is down, the filesystem is full, or the lock is poisoned. We will replace `eprintln!` with structured logging via `tracing` in Part 16, but the principle is the same: the internal details are for the server operator, not the client. ### From Impls - Automatic Error Conversion The `?` operator is the backbone of Rust error handling. It converts one error type into another via `From`. If we want to use `?` in our handlers, we need `From` impls that convert standard library and framework errors into `AppError`: ```rust impl From>>> for AppError { fn from(_: std::sync::PoisonError>>) -> Self { AppError::InternalError("lock poisoned".into()) } } ``` That type signature is... a lot. It is for `RwLockWriteGuard` specifically. We would need similar impls for `RwLockReadGuard`. Fortunately, in practice you rarely write these by hand. Many projects use `thiserror` to derive conversions for their own error types, while specific errors like `PoisonError` are often handled explicitly with `.map_err()` or wrapped in another error type. In practice, many projects use `thiserror` to reduce boilerplate for their own error types. Since our goal here is to understand what `From` is doing under the hood, we'll write the conversion manually instead. ## Project Setup Create a new project: ``` cargo new pastebin_api cd pastebin_api ``` Open `Cargo.toml` and add the dependencies: ```toml [package] name = "pastebin_api" version = "0.1.0" edition = "2024" [dependencies] axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } ``` We have two new dependencies compared to Part 1: - `chrono` handles timestamps and expiry calculations. The `serde` feature lets us serialize and deserialize `DateTime` as ISO 8601 strings, which is the standard JSON representation for dates. - `uuid` for generating unique paste IDs, same as Part 1. ## The Project: Pastebin API Our program will: - Accept `POST /paste` with a JSON body containing content, an optional language, and an optional expiry duration - Validate the input like reject empty content, oversized content, unsupported languages, and negative expiry - Generate a unique ID and store the paste in memory with a creation timestamp - Accept `GET /paste/{id}` that returns the paste as JSON - Return 404 if the paste does not exist or has expired - Return 400 for any validation failure with a specific message - Return 500 for any internal failure with a generic message ### Data Model A paste is defined by six fields: ```rust struct Paste { id: String, content: String, language: Option, created_at: DateTime, expires_at: Option>, } ``` `language` is optional, not all pastes are code. `expires_at` is optional as pastes can live forever. `created_at` is always set, using the server's clock at creation time. ### Supported Languages We limit language hints to a known set. This prevents typos like `"javscript"` and gives the client a clear error message: ```rust const SUPPORTED_LANGUAGES: &[&str] = &[ "rust", "python", "javascript", "typescript", "go", "java", "c", "cpp", "ruby", "php", "swift", "kotlin", "scala", "elixir", "haskell", "bash", "sql", "html", "css", "json", "yaml", "toml", "markdown", "plaintext", ]; ``` ### Maximum Content Length We cap content at 500 KiB (500 × 1024 bytes). Larger pastes should be stored differently as object storage, not an in-memory API: ```rust const MAX_CONTENT_LENGTH: usize = 500 * 1024; // 500 KiB ``` ### The Full Code Replace everything in `src/main.rs` with this: ```rust use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; use tokio::net::TcpListener; use uuid::Uuid; const SUPPORTED_LANGUAGES: &[&str] = &[ "rust", "python", "javascript", "typescript", "go", "java", "c", "cpp", "ruby", "php", "swift", "kotlin", "scala", "elixir", "haskell", "bash", "sql", "html", "css", "json", "yaml", "toml", "markdown", "plaintext", ]; const MAX_CONTENT_LENGTH: usize = 500 * 1024; #[derive(Clone)] struct AppState { pastes: Arc>>, } #[derive(Clone, Serialize)] struct Paste { id: String, content: String, language: Option, created_at: DateTime, expires_at: Option>, } #[derive(Deserialize)] struct CreatePasteRequest { content: String, language: Option, expires_in_seconds: Option, } #[derive(Serialize)] struct CreatePasteResponse { id: String, } #[derive(Serialize)] struct GetPasteResponse { id: String, content: String, language: Option, created_at: DateTime, expires_at: Option>, } enum AppError { ValidationError(String), NotFound(String), InternalError(String), } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.clone()), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), AppError::InternalError(msg) => { eprintln!("internal error: {}", msg); ( StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string(), ) } }; let body = Json(serde_json::json!({ "error": message, })); (status, body).into_response() } } impl From>> for AppError { fn from(_: std::sync::PoisonError>) -> Self { AppError::InternalError("lock poisoned".into()) } } impl From>> for AppError { fn from(_: std::sync::PoisonError>) -> Self { AppError::InternalError("lock poisoned".into()) } } #[tokio::main] async fn main() { let state = AppState { pastes: Arc::new(RwLock::new(HashMap::new())), }; let app = Router::new() .route("/paste", post(create_paste)) .route("/paste/{id}", get(get_paste)) .with_state(state); let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); } async fn create_paste( State(state): State, Json(payload): Json, ) -> Result { validate_create_request(&payload)?; let id = &Uuid::new_v4().to_string()[..8]; let expires_at = payload.expires_in_seconds.map(|seconds| { Utc::now() + Duration::seconds(seconds) }); let paste = Paste { id: id.to_string(), content: payload.content, language: payload.language, created_at: Utc::now(), expires_at, }; state.pastes.write()?.insert(id.to_string(), paste); Ok(( StatusCode::CREATED, Json(CreatePasteResponse { id: id.to_string() }), )) } async fn get_paste( State(state): State, Path(id): Path, ) -> Result { let pastes = state.pastes.read()?; let paste = pastes .get(&id) .ok_or_else(|| AppError::NotFound(format!("paste with id '{}' not found", id)))?; if let Some(expires_at) = paste.expires_at { if Utc::now() > expires_at { return Err(AppError::NotFound(format!( "paste with id '{}' has expired", id ))); } } let response = GetPasteResponse { id: paste.id.clone(), content: paste.content.clone(), language: paste.language.clone(), created_at: paste.created_at, expires_at: paste.expires_at, }; Ok((StatusCode::OK, Json(response))) } fn validate_create_request(req: &CreatePasteRequest) -> Result<(), AppError> { if req.content.trim().is_empty() { return Err(AppError::ValidationError( "content must not be empty".into(), )); } if req.content.len() > MAX_CONTENT_LENGTH { return Err(AppError::ValidationError(format!( "content exceeds maximum length of {} bytes (got {} bytes)", MAX_CONTENT_LENGTH, req.content.len() ))); } if let Some(ref lang) = req.language { if !SUPPORTED_LANGUAGES.contains(&lang.as_str()) { return Err(AppError::ValidationError(format!( "unsupported language '{}'. supported languages: {}", lang, SUPPORTED_LANGUAGES.join(", ") ))); } } if let Some(seconds) = req.expires_in_seconds { if seconds <= 0 { return Err(AppError::ValidationError( "expires_in_seconds must be greater than 0".into(), )); } } Ok(()) } ``` Now, let me explain what we just did. ### The State ```rust #[derive(Clone)] struct AppState { pastes: Arc>>, } ``` Same pattern as Part 1. `HashMap` mapping paste IDs to `Paste` structs, wrapped in `Arc>` for shared concurrent access. The difference is the value type, `Paste` is a struct with six fields, not just a `String`. `Paste` derives `Clone` and `Serialize`. `Clone` is necessary because `HashMap::get` returns a reference, and we need to clone some fields when building the response. `Serialize` is for the `DateTime` fields to serialize as ISO 8601 strings. ### The Request and Response Types Three request/response types, designed for three concerns: `CreatePasteRequest` - what the client sends. `content` is required. `language` and `expires_in_seconds` are optional. All validation logic references these fields. `CreatePasteResponse` - what the server returns after creating a paste. Just the ID. The client can use that ID to fetch the full paste. `GetPasteResponse` - the full paste returned on read. Includes the ID, content, language, timestamps, and expiry. This is the external representation. Notice it is a separate type from `Paste`, the internal `Paste` struct stores data, the response type formats it for the client. > **Why separate request/response types from domain types?** The Paste struct is our internal representation. It has an `id` field that the client never sends on creation (we generate it). The `CreatePasteRequest` does not have an `id` field. Separating these types prevents the client from setting `id` and prevents us from accidentally returning internal fields. This is a pattern you will see in every well-structured Rust API. ### The AppError Enum and IntoResponse ```rust enum AppError { ValidationError(String), NotFound(String), InternalError(String), } ``` Three variants, one per HTTP error category. The `IntoResponse` implementation is the single place where errors become HTTP responses: ```rust impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.clone()), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), AppError::InternalError(msg) => { eprintln!("internal error: {}", msg); ( StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string(), ) } }; let body = Json(serde_json::json!({ "error": message, })); (status, body).into_response() } } ``` Every error response is `{"error": "..."}` with the appropriate status code. The client always gets a consistent JSON structure, regardless of which handler produced the error. If you later decide to add a `request_id` field to every error response, you change one function, not twenty handlers. The `InternalError` variant logs the actual error to stderr via `eprintln!` but sends a generic message to the client: `{"error": "internal server error"}`. We will replace `eprintln!` with structured logging via `tracing` in Part 16, but the principle is the same, internal details are for the server operator, never for the client. ### From Impls for Lock Poisoning ```rust impl From>> for AppError { fn from(_: std::sync::PoisonError>) -> Self { AppError::InternalError("lock poisoned".into()) } } impl From>> for AppError { fn from(_: std::sync::PoisonError>) -> Self { AppError::InternalError("lock poisoned".into()) } } ``` These impls let us use `?` with lock acquisition. Instead of `.unwrap()`, we write: ```rust let pastes = state.pastes.read()?; ``` The `?` converts `PoisonError>>` into `AppError::InternalError`, which `IntoResponse` converts into a 500 response. The handler never panics on a poisoned lock. > **What is a poisoned lock?** When a thread panics while holding a `Mutex` or `RwLock`, the lock is "poisoned", subsequent attempts to acquire it return an error. This prevents other threads from reading data that might be in an inconsistent state (the panicking thread might have been halfway through an update). In our case, the data is a `HashMap` of pastes. Rust does allow recovering from a poisoned lock, but this example intentionally treats it as an internal server error and returns a `500` instead. ### The Create Paste Handler ```rust async fn create_paste( State(state): State, Json(payload): Json, ) -> Result { validate_create_request(&payload)?; let id = &Uuid::new_v4().to_string()[..8]; let expires_at = payload.expires_in_seconds.map(|seconds| { Utc::now() + Duration::seconds(seconds) }); let paste = Paste { id: id.to_string(), content: payload.content, language: payload.language, created_at: Utc::now(), expires_at, }; state.pastes.write()?.insert(id.to_string(), paste); Ok(( StatusCode::CREATED, Json(CreatePasteResponse { id: id.to_string() }), )) } ``` Notice the return type: `Result`. This is the key pattern. The `Ok` branch returns a success response, a 201 with a JSON body. The `Err` branch is an `AppError`, which Axum converts via `IntoResponse`. The `?` operator on `validate_create_request` and `state.pastes.write()` converts failures into `AppError` automatically. `validate_create_request(&payload)?` runs all four validation checks before any work is done. If any check fails, the error is returned immediately. This is "fail fast", do not start creating a paste if the input is invalid. `payload.expires_in_seconds.map(|seconds| { ... })` converts the optional expiry duration into an absolute timestamp. If `expires_in_seconds` is `None`, `expires_at` is `None`, the paste lives forever. If it is `Some(3600)`, `expires_at` is `Utc::now() + 1 hour`. > For simplicity, we shorten the UUID to eight hexadecimal characters. Production systems typically use the full UUID or another identifier with collision guarantees appropriate for their scale. ### The Get Paste Handler ```rust async fn get_paste( State(state): State, Path(id): Path, ) -> Result { let pastes = state.pastes.read()?; let paste = pastes .get(&id) .ok_or_else(|| AppError::NotFound(format!("paste with id '{}' not found", id)))?; if let Some(expires_at) = paste.expires_at { if Utc::now() > expires_at { return Err(AppError::NotFound(format!( "paste with id '{}' has expired", id ))); } } let response = GetPasteResponse { id: paste.id.clone(), content: paste.content.clone(), language: paste.language.clone(), created_at: paste.created_at, expires_at: paste.expires_at, }; Ok((StatusCode::OK, Json(response))) } ``` `pastes.get(&id)` returns `Option<&Paste>`. `.ok_or_else(|| ...)` converts `None` into an `AppError::NotFound`. The `?` propagates that error if the paste does not exist. The expiry check happens after the lookup. If the paste exists but has expired, we return the same `NotFound` error. From the client's perspective, an expired paste and a non-existent paste are identical, you cannot access either one. A real application might delete expired pastes from the map periodically (a background cleanup task), but for this in-memory implementation, we check at read time. The response construction clones the fields that are `String` or `Option`. `DateTime` implements `Copy`, so we can pass it directly. Cloning strings is not free, and cloning the paste content is the most expensive part of this handler. Production systems often avoid unnecessary copies by using shared ownership types such as `Arc` or other ownership strategies, depending on the application's requirements. ### The Validation Function ```rust fn validate_create_request(req: &CreatePasteRequest) -> Result<(), AppError> { if req.content.trim().is_empty() { return Err(AppError::ValidationError("content must not be empty".into())); } if req.content.len() > MAX_CONTENT_LENGTH { return Err(AppError::ValidationError(format!( "content exceeds maximum length of {} bytes (got {} bytes)", MAX_CONTENT_LENGTH, req.content.len() ))); } if let Some(ref lang) = req.language { if !SUPPORTED_LANGUAGES.contains(&lang.as_str()) { return Err(AppError::ValidationError(format!( "unsupported language '{}'. supported languages: {}", lang, SUPPORTED_LANGUAGES.join(", ") ))); } } if let Some(seconds) = req.expires_in_seconds { if seconds <= 0 { return Err(AppError::ValidationError( "expires_in_seconds must be greater than 0".into(), )); } } Ok(()) } ``` A standalone function, not a method on the handler. This keeps the handler clean and makes the validation logic testable independently, you can call `validate_create_request` in a unit test without setting up an entire Axum router. Four checks, each returning a specific error message: 1. **Content must not be empty** - `"".trim()` is empty, `" ".trim()` is empty, `"hi".trim()` is not empty. Trimming prevents pastes that look empty but contain only whitespace. 2. **Content must not exceed 500 KiB** - includes the actual byte count in the error message so the client knows how much to trim. 3. **Language must be supported** - lists all supported languages in the error message. This is a self-documenting API: the error response tells the client what valid values look like. 4. **Expiry must be positive** - rejects zero and negative values. Zero-second expiry is nonsensical. Negative expiry is almost certainly a bug. ## Running the Project Start the server: ``` cargo run ``` You should see: ``` Listening on http://127.0.0.1:3000 ``` ### Create a Paste ``` curl -X POST http://localhost:3000/paste \ -H "Content-Type: application/json" \ -d '{"content": "fn main() {\n println!(\"hello world\");\n}", "language": "rust"}' ``` Response: ```json {"id":"a1b2c3d4"} ``` ### Read the Paste ``` curl http://localhost:3000/paste/a1b2c3d4 ``` Response: ```json { "id": "a1b2c3d4", "content": "fn main() {\n println!(\"hello world\");\n}", "language": "rust", "created_at": "2026-07-06T12:00:00Z", "expires_at": null } ``` ### Create a Paste with Expiry ``` curl -X POST http://localhost:3000/paste \ -H "Content-Type: application/json" \ -d '{"content": "this will expire soon", "expires_in_seconds": 5}' ``` Wait 6 seconds, then read it: ``` curl http://localhost:3000/paste/b2c3d4e5 ``` Response: ```json {"error":"paste with id 'b2c3d4e5' has expired"} ``` Status code: 404. ### Test Validation - Empty Content ``` curl -X POST http://localhost:3000/paste \ -H "Content-Type: application/json" \ -d '{"content": ""}' ``` Response: ```json {"error":"content must not be empty"} ``` Status code: 400. ### Test Validation - Unsupported Language ``` curl -X POST http://localhost:3000/paste \ -H "Content-Type: application/json" \ -d '{"content": "some code", "language": "brainfuck"}' ``` Response: ```json {"error":"unsupported language 'brainfuck'. supported languages: rust, python, ..."} ``` Status code: 400. ### Test Validation - Negative Expiry ``` curl -X POST http://localhost:3000/paste \ -H "Content-Type: application/json" \ -d '{"content": "some code", "expires_in_seconds": -10}' ``` Response: ```json {"error":"expires_in_seconds must be greater than 0"} ``` Status code: 400. ### Test Structural Error - Missing Content ``` curl -X POST http://localhost:3000/paste \ -H "Content-Type: application/json" \ -d '{"language": "rust"}' ``` Response: `422 Unprocessable Entity`. Axum generates this response before our handler runs because deserializing the request into `CreatePasteRequest` fails when the required `content` field is missing. ### Test Structural Error - Wrong Content-Type ``` curl -X POST http://localhost:3000/paste \ -H "Content-Type: text/plain" \ -d '{"content": "hello"}' ``` Response: 415 Unsupported Media Type. Axum rejects this because the request is not using a JSON media type. ### Test Not Found ``` curl http://localhost:3000/paste/nonexistent ``` Response: ```json {"error":"paste with id 'nonexistent' not found"} ``` Status code: 404. ## The Boundary Between Framework and Application Look at the curl examples again. There are three categories of failure: 1. **Structural errors** (422, 415) - handled by Axum automatically. Wrong content types, malformed JSON, and deserialization failures. You never write code for these. 2. **Semantic errors** (400) - handled by your validation function. Empty content, unsupported language, negative expiry. You write explicit checks and return `AppError::ValidationError`. 3. **Resource errors** (404) - handled by your handler logic. Missing paste, expired paste. You check the lookup result and return `AppError::NotFound`. This is the boundary Axum draws. The framework handles everything it can detect from the structure of the request. You handle everything that requires understanding the meaning of the data. There is no overlap, no ambiguity, no framework guessing what status code your error should be. This is the opposite of "convention over configuration." Axum says: "I will handle the HTTP plumbing - parsing, routing, serialization. You handle every business decision, including what status code means what. There is no hidden application-level error handling." ## How an Error Flows Through Let us trace a single `GET /paste/nonexistent` request to see how the `AppError` pattern works end-to-end: 1. The router matches `/paste/{id}` with `id = "nonexistent"` and selects `get_paste` as the handler. 2. `State` extracts the state (clones the `Arc`, cheap). 3. `Path` extracts `"nonexistent"` from the path. 4. The handler runs. `state.pastes.read()?` succeeds (the lock is not poisoned). `pastes.get(&id)` returns `None`. `.ok_or_else(|| AppError::NotFound(...))` converts `None` into `Err(AppError::NotFound("paste with id 'nonexistent' not found"))`. The `?` operator propagates the error out of the handler. 5. The handler returns `Err(AppError::NotFound(...))`. Because the return type is `Result`, Axum calls `AppError::into_response()` on the error. 6. `AppError::into_response()` matches `NotFound`, sets the status to 404, formats the error message as `{"error": "paste with id 'nonexistent' not found"}`, and returns an `http::Response`. 7. The response travels back through Hyper and Tokio, same as a success response. Compare this to the Part 1 error handling: an ad-hoc `(StatusCode::NOT_FOUND, &str)` in the handler. The new pattern separates error construction (in the handler) from error formatting (in `IntoResponse`). The handler says *what* went wrong. The `IntoResponse` implementation decides *how* to represent that to the client. ## What We Skipped - **Persistence**: Pastes live in memory and disappear on restart. Part 3 replaces the `HashMap` with PostgreSQL via SQLx. - **Pagination**: We return a single paste by ID. A real API would have `GET /pastes?language=rust&page=1` for listing. We touched on `Query` parameters, but full pagination with SQL joins comes in Part 3. - **Content type validation on read**: A paste with `language: "markdown"` would ideally return `Content-Type: text/markdown`. For simplicity, we always return JSON. - **Expired paste cleanup**: Expired pastes stay in the `HashMap` forever, only rejected at read time. A background task that periodically removes expired entries would be the production pattern. Background tasks are Part 9. - **The thiserror crate**: We wrote `From` impls manually to show the mechanism. In practice, `thiserror` derives these for you. The manual impls are educational; the derive macro is what you use at work. ## Conclusion In this post, you built a Pastebin API while learning Axum's extractors, validation, and centralized error handling. Next, we'll replace the in-memory `HashMap` with PostgreSQL and SQLx. If you like reading this, please subscribe and share this with others. It will really help me and motivate me to keep publishing more such articles. # Learn Axum Persistence and Transactions with SQLx by Building a Bookmark Manager > In this post, we are going to build a Bookmark manager API with PostgreSQL Source: https://blog.sheerluck.dev/posts/axum/learn-axum-persistence-and-transaction-by-building-a-bookmark-manager/ · Markdown: https://blog.sheerluck.dev/posts/axum/learn-axum-persistence-and-transaction-by-building-a-bookmark-manager/index.md In Part 1 and Part 2, we built a URL shortener and a Pastebin API. Both had the same limitation: data lived in a `HashMap` wrapped in `Arc>`. Restart the server, everything disappears. That was intentional. We were learning routing, extractors, and error handling, persistence would have been a distraction. ![Axum Bookmark Manager](/images/axum-bookmark-manager.png) In this post, we are going to fix that. We will replace the `HashMap` with PostgreSQL and SQLx. Adding a real database introduces connection pooling, schema design, migrations, compile-time-checked queries, joins, pagination, the N+1 query trap, explicit transactions, and isolation levels. Every one of these concepts matters in production, and every one of them is easier to learn in a single-service project than under the pressure of a distributed system. Let's start, I can't wait. Get the source code from [here](https://github.com/MrSheerluck/rust-bookmark-manager-api) ## The Problem with In-Memory Storage The `HashMap` we used in Parts 1 and 2 has three problems. First, data is lost on restart. Second, capacity is bounded by available RAM. Third, and most subtly, there is no transactional safety. In Part 2, we created a paste by inserting it into a map. That operation is atomic only because `HashMap::insert` is a single method call. The moment you have a multi-step operation like "create a bookmark and attach three tags" and the third tag insertion fails, you have a bookmark in the map with two orphaned tags and no way to undo the first two inserts. Real persistence solves all three: durability (survives restarts), scalability (data lives on disk, not in RAM), and atomicity (all-or-nothing multi-step operations via transactions). ## What is SQLx? SQLx is a Rust crate for talking to SQL databases. It is async-native (built on Tokio), supports PostgreSQL, MySQL, and SQLite, and provides connection pooling. But its defining feature, the one that matters to this article is **compile-time checking of SQL queries**. In most ORMs and query builders, a typo in a column name or a type mismatch between a Rust struct and a SQL column is a runtime error. You write `SELECT tite FROM bookmarks`, deploy to production, and find out when the first request hits that endpoint and panics. SQLx validates those queries during compilation. It parses every SQL string in `query!` and `query_as!` and verifies it against either a live database schema or the cached metadata in the `.sqlx/` directory. If a column is missing, the types don't match, or the SQL is invalid, compilation fails before the application ever runs. `cargo sqlx prepare` connects to your database and generates query metadata in the `.sqlx/` directory. During offline compilation, the macros read this metadata instead of connecting to a live database. This allows SQL queries to remain compile-time checked even in CI environments without database access. If a column is missing, `cargo build` fails with a clear message pointing to the exact query and the exact mismatch. For queries written with SQLx's checked macros, typos and schema mismatches are caught before the application is built. > **Why not an ORM like Diesel?** SQLx is deliberately *not* an ORM. It does not generate SQL from Rust structs. You write raw SQL in macros, and SQLx checks it for you. This gives you full control over the SQL joins, window functions, CTEs while still catching mistakes at compile time. If you prefer ORMs, Diesel is the go-to in Rust. This series uses SQLx because the goal is to learn backend engineering, and I want you to understand the SQL that your application is actually running. ## Connection Pooling A connection pool is a cache of open database connections. Creating a new TCP connection to Postgres for every request is slow, TLS handshakes, authentication, and session setup take milliseconds. A pool keeps a set of connections open and ready, handing one to each request that needs it and returning it when the request is done. SQLx provides `PgPool` for this: ```rust let pool = PgPoolOptions::new() .max_connections(10) .connect(&database_url) .await?; ``` `max_connections(10)` limits the pool to 10 simultaneous connections. Every request handler can call `pool.acquire()` to get a connection from the pool. If all 10 are in use, the 11th request waits until one is returned. Acquiring a connection from the pool is much cheaper than establishing a new database connection because the TCP connection, authentication, and session setup have already been completed. The pool itself is cheap to clone. `PgPool` wraps an `Arc`, so cloning it just increments a reference count exactly like cloning an `Arc`. This means you can put the pool directly in your `AppState` and use `#[derive(Clone)]` on the state struct without wrapping the pool in an extra `Arc`: ```rust #[derive(Clone)] struct AppState { pool: PgPool, // no Arc needed, PgPool is already Arc inside } ``` ## Database Migrations A schema changes over time. You add a `description` column to `bookmarks`, or an index on `created_at`. These changes are called migrations. SQLx provides a CLI for managing them: ``` cargo install sqlx-cli --no-default-features --features postgres sqlx migrate add initial_schema ``` This creates a file in `migrations/` with a timestamp prefix. You write your SQL in that file, and then: ``` sqlx migrate run ``` This applies any unapplied migrations to your database. SQLx tracks which migrations have been run in a `_sqlx_migrations` table, so it never applies the same migration twice. Our application also runs migrations at startup via `sqlx::migrate!("./migrations").run(&pool)`. This keeps the schema synchronized with the application during development. Some production deployments instead run migrations as a separate deployment step before starting the application. ## Project Setup We need PostgreSQL. The simplest way is Docker: ``` docker compose up -d ``` Create a `docker-compose.yml`: ```yaml services: postgres: image: postgres:16-alpine container_name: bookmark_db restart: unless-stopped ports: - "5432:5432" environment: POSTGRES_USER: bookmark POSTGRES_PASSWORD: bookmark POSTGRES_DB: bookmark volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata: ``` This gives us Postgres 16 on port 5432, with a `bookmark` database, user, and password. The `pgdata` volume persists data across container restarts. Create a `.env` file for SQLx: ``` DATABASE_URL=postgres://bookmark:bookmark@localhost:5432/bookmark ``` Create the project: ``` cargo new bookmark_manager cd bookmark_manager ``` Open `Cargo.toml`: ```toml [package] name = "bookmark_manager" version = "0.1.0" edition = "2024" [dependencies] axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "migrate", "chrono", "uuid"] } uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" ``` The new dependencies worth explaining: - `sqlx` with five features: `runtime-tokio` (async runtime), `tls-rustls` (TLS for secure connections), `postgres` (database driver), `migrate` (run migrations from code), and `chrono` + `uuid` (type support for `DateTime` and `Uuid` columns). Without the `chrono` feature, SQLx wouldn't know how to read a `TIMESTAMPTZ` column into Rust's `DateTime`. - `dotenvy` loads the `.env` file at startup so `DATABASE_URL` is available as an environment variable. - `uuid` now has the `serde` feature in addition to `v4` for serializing/deserializing UUIDs in JSON. Install `sqlx-cli` and prepare the offline query cache: ``` cargo install sqlx-cli --no-default-features --features postgres cargo sqlx prepare ``` The `prepare` command connects to your database, extracts the current schema, and generates a `.sqlx/` directory with cached query metadata. This is what the compiler uses to check your SQL. You should commit the `.sqlx/` directory to version control. It contains the metadata SQLx uses to verify queries during offline compilation, allowing CI to build the project without requiring a running database. ## The Schema Create `migrations/20260712000000_initial_schema.sql`: ```sql CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE TABLE bookmarks ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), url TEXT NOT NULL, title TEXT NOT NULL, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE tags ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name TEXT NOT NULL UNIQUE ); CREATE TABLE bookmark_tags ( bookmark_id UUID NOT NULL REFERENCES bookmarks(id) ON DELETE CASCADE, tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (bookmark_id, tag_id) ); CREATE INDEX idx_bookmarks_created_at ON bookmarks(created_at DESC); CREATE INDEX idx_bookmark_tags_bookmark_id ON bookmark_tags(bookmark_id); CREATE INDEX idx_bookmark_tags_tag_id ON bookmark_tags(tag_id); ``` Now, let me explain each table and the design decisions. ### bookmarks The core entity. Each row has a UUID primary key, a URL (the thing we are bookmarking), a title, an optional description, and two timestamps. `UUID` primary keys have two advantages over auto-incrementing integers in an API: they are unguessable (no sequential IDs to enumerate), and they can be generated client-side or server-side without coordination. `uuid_generate_v4()` generates a random (v4) UUID. This requires the `uuid-ossp` extension, enabled at the top of the migration. Random UUIDs are slightly slower to insert than sequential identifiers because they fragment B-tree indexes. For many web applications this overhead is acceptable, while write-heavy systems often prefer sequential identifiers or newer UUID variants such as UUIDv7. ### tags Tags are shared across bookmarks. A tag like `rust` can be attached to many bookmarks. The `name` column has a `UNIQUE` constraint, you cannot insert the same tag name twice. This constraint is essential because without it, two concurrent requests could create duplicate `rust` rows in the `tags` table, and the `bookmark_tags` junction table would then reference two different rows for what should be the same tag. ### bookmark_tags This is the **junction table** (also called a join table, association table, or linking table). It models the many-to-many relationship between bookmarks and tags. One bookmark can have many tags. One tag can be attached to many bookmarks. A relational database cannot represent this directly with a foreign key in either table, you need a third table that pairs them. Each row in `bookmark_tags` is a pair of foreign keys: `bookmark_id` references `bookmarks(id)`, and `tag_id` references `tags(id)`. The composite primary key `(bookmark_id, tag_id)` ensures you can never attach the same tag to the same bookmark twice. `ON DELETE CASCADE` on both foreign keys means deleting a bookmark automatically removes all its tag associations, and deleting a tag removes all its associations. ### Indexes Three indexes, designed for our access patterns: - `idx_bookmarks_created_at` supports the `ORDER BY created_at DESC` used in the list endpoint. Without this index, Postgres would sort the entire `bookmarks` table on every request. - `idx_bookmark_tags_bookmark_id` supports the `WHERE bt.bookmark_id = $1` join used to fetch tags for a bookmark. - `idx_bookmark_tags_tag_id` supports the `WHERE t.name = $1` join used to filter bookmarks by tag. Indexes are a trade-off: they speed up reads at the cost of slower writes (every insert must also update the index) and additional disk usage. For this application, reads are far more common than writes, users browse their bookmarks more often than they create new ones so the indexes are worth it. ## The Project: Bookmark Manager Our program will: - Accept `POST /bookmarks` with a JSON body containing a URL, title, optional description, and optional tags - Create the bookmark and attach any tags in a single transaction - Accept `GET /bookmarks` with optional `?page=`, `?per_page=`, and `?tag=` query parameters for paginated listing - Accept `GET /bookmarks/{id}` that returns a single bookmark with its tags - Accept `PUT /bookmarks/{id}` to update a bookmark's fields - Accept `DELETE /bookmarks/{id}` to delete a bookmark (tags are cascade-deleted) - Accept `POST /bookmarks/{id}/tags` to attach new tags to an existing bookmark - Accept `DELETE /bookmarks/{id}/tags/{tag_id}` to detach a single tag ### Data Model The internal `Bookmark` struct maps directly to the `bookmarks` table: ```rust #[derive(Debug, Serialize, sqlx::FromRow)] struct Bookmark { id: Uuid, url: String, title: String, description: Option, created_at: DateTime, updated_at: DateTime, } ``` `#[derive(sqlx::FromRow)]` is what makes `query_as!(Bookmark, ...)` work. It generates code that reads each column from a database row and constructs a `Bookmark`. The field names in the struct must match the column names in the database, and the types must be compatible `Uuid` ↔ `UUID`, `String` ↔ `TEXT`, `Option` ↔ nullable `TEXT`, `DateTime` ↔ `TIMESTAMPTZ`. If any field is missing or has the wrong type, `cargo build` catches it. `Tag` follows the same pattern: ```rust #[derive(Debug, Clone, Serialize, sqlx::FromRow)] struct Tag { id: Uuid, name: String, } ``` `BookmarkResponse` is the external representation. It includes tags alongside the bookmark fields. This is a separate type from `Bookmark` , the internal `Bookmark` struct is a direct row mapping, while `BookmarkResponse` includes computed data (the joined tags): ```rust #[derive(Debug, Serialize)] struct BookmarkResponse { id: Uuid, url: String, title: String, description: Option, created_at: DateTime, updated_at: DateTime, tags: Vec, } ``` Why separate `BookmarkResponse` from `Bookmark`? `Bookmark` represents a single row in the `bookmarks` table. `BookmarkResponse` represents the JSON the client sees, which includes tags fetched from a separate query. The response type is a view, it combines data from multiple sources into one shape. The internal type is a model, it maps directly to storage. Keeping them separate lets you change the database schema without changing the API response shape, and vice versa. ### The Full Code Replace everything in `src/main.rs` with this: ```rust use axum::{ extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{delete, get, post, put}, Json, Router, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use uuid::Uuid; #[derive(Clone)] struct AppState { pool: PgPool, } #[derive(Debug, Serialize, sqlx::FromRow)] struct Bookmark { id: Uuid, url: String, title: String, description: Option, created_at: DateTime, updated_at: DateTime, } #[derive(Debug, Clone, Serialize, sqlx::FromRow)] struct Tag { id: Uuid, name: String, } #[derive(Debug, Deserialize)] struct CreateBookmarkRequest { url: String, title: String, description: Option, tags: Option>, } #[derive(Debug, Deserialize)] struct UpdateBookmarkRequest { url: Option, title: Option, description: Option, } #[derive(Debug, Serialize)] struct BookmarkResponse { id: Uuid, url: String, title: String, description: Option, created_at: DateTime, updated_at: DateTime, tags: Vec, } #[derive(Debug, Deserialize)] struct PaginationParams { page: Option, per_page: Option, tag: Option, } #[derive(Debug, Serialize)] struct PaginatedBookmarks { bookmarks: Vec, page: i64, per_page: i64, total: i64, } enum AppError { ValidationError(String), NotFound(String), InternalError(String), } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.clone()), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), AppError::InternalError(msg) => { eprintln!("internal error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()) } }; let body = Json(serde_json::json!({ "error": message })); (status, body).into_response() } } impl From for AppError { fn from(err: sqlx::Error) -> Self { match err { sqlx::Error::RowNotFound => { AppError::NotFound("resource not found".into()) } _ => AppError::InternalError(format!("database error: {}", err)), } } } #[tokio::main] async fn main() { dotenvy::dotenv().ok(); let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let pool = PgPoolOptions::new() .max_connections(10) .connect(&database_url) .await .expect("failed to connect to database"); sqlx::migrate!("./migrations") .run(&pool) .await .expect("failed to run migrations"); let state = AppState { pool }; let app = Router::new() .route("/bookmarks", post(create_bookmark)) .route("/bookmarks", get(list_bookmarks)) .route("/bookmarks/{id}", get(get_bookmark)) .route("/bookmarks/{id}", put(update_bookmark)) .route("/bookmarks/{id}", delete(delete_bookmark)) .route("/bookmarks/{id}/tags", post(attach_tags)) .route("/bookmarks/{id}/tags/{tag_id}", delete(detach_tag)) .route("/healthz", get(healthz)) .with_state(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); } async fn healthz() -> impl IntoResponse { StatusCode::OK } async fn create_bookmark( State(state): State, Json(payload): Json, ) -> Result { if payload.url.trim().is_empty() || payload.title.trim().is_empty() { return Err(AppError::ValidationError( "url and title must not be empty".into(), )); } let mut tx = state.pool.begin().await?; let bookmark = sqlx::query_as!( Bookmark, r#"INSERT INTO bookmarks (url, title, description) VALUES ($1, $2, $3) RETURNING id, url, title, description, created_at, updated_at"#, payload.url, payload.title, payload.description, ) .fetch_one(&mut *tx) .await?; let mut tags = Vec::new(); if let Some(ref tag_names) = payload.tags { for name in tag_names { let tag = sqlx::query_as!( Tag, r#"INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id, name"#, name.trim().to_lowercase(), ) .fetch_one(&mut *tx) .await?; sqlx::query!( "INSERT INTO bookmark_tags (bookmark_id, tag_id) VALUES ($1, $2)", bookmark.id, tag.id, ) .execute(&mut *tx) .await?; tags.push(tag); } } tx.commit().await?; Ok(( StatusCode::CREATED, Json(BookmarkResponse { id: bookmark.id, url: bookmark.url, title: bookmark.title, description: bookmark.description, created_at: bookmark.created_at, updated_at: bookmark.updated_at, tags, }), )) } async fn get_bookmark( State(state): State, Path(id): Path, ) -> Result { let bookmark = sqlx::query_as!( Bookmark, "SELECT id, url, title, description, created_at, updated_at FROM bookmarks WHERE id = $1", id, ) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("bookmark with id '{}' not found", id)))?; let tags = sqlx::query_as!( Tag, r#"SELECT t.id, t.name FROM tags t INNER JOIN bookmark_tags bt ON bt.tag_id = t.id WHERE bt.bookmark_id = $1"#, bookmark.id, ) .fetch_all(&state.pool) .await?; Ok(Json(BookmarkResponse { id: bookmark.id, url: bookmark.url, title: bookmark.title, description: bookmark.description, created_at: bookmark.created_at, updated_at: bookmark.updated_at, tags, })) } async fn list_bookmarks( State(state): State, Query(params): Query, ) -> Result { let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * per_page; let (bookmarks, total): (Vec, i64) = if let Some(ref tag_name) = params.tag { let total = sqlx::query_scalar!( r#"SELECT COUNT(DISTINCT b.id)::bigint FROM bookmarks b INNER JOIN bookmark_tags bt ON bt.bookmark_id = b.id INNER JOIN tags t ON t.id = bt.tag_id WHERE t.name = $1"#, tag_name.trim().to_lowercase(), ) .fetch_one(&state.pool) .await? .unwrap_or(0); let bookmarks = sqlx::query_as!( Bookmark, r#"SELECT DISTINCT b.id, b.url, b.title, b.description, b.created_at, b.updated_at FROM bookmarks b INNER JOIN bookmark_tags bt ON bt.bookmark_id = b.id INNER JOIN tags t ON t.id = bt.tag_id WHERE t.name = $1 ORDER BY b.created_at DESC LIMIT $2 OFFSET $3"#, tag_name.trim().to_lowercase(), per_page, offset, ) .fetch_all(&state.pool) .await?; (bookmarks, total) } else { let total = sqlx::query_scalar!("SELECT COUNT(*)::bigint FROM bookmarks") .fetch_one(&state.pool) .await? .unwrap_or(0); let bookmarks = sqlx::query_as!( Bookmark, r#"SELECT id, url, title, description, created_at, updated_at FROM bookmarks ORDER BY created_at DESC LIMIT $1 OFFSET $2"#, per_page, offset, ) .fetch_all(&state.pool) .await?; (bookmarks, total) }; let bookmark_ids: Vec = bookmarks.iter().map(|b| b.id).collect(); #[derive(Debug, sqlx::FromRow)] struct TagWithBookmark { id: Uuid, name: String, bookmark_id: Uuid, } let all_tags: Vec = if bookmark_ids.is_empty() { Vec::new() } else { sqlx::query_as!( TagWithBookmark, r#"SELECT t.id, t.name, bt.bookmark_id FROM tags t INNER JOIN bookmark_tags bt ON bt.tag_id = t.id WHERE bt.bookmark_id = ANY($1)"#, &bookmark_ids as &[Uuid], ) .fetch_all(&state.pool) .await? }; use std::collections::HashMap; let mut tags_by_bookmark: HashMap> = HashMap::new(); for row in all_tags { tags_by_bookmark .entry(row.bookmark_id) .or_default() .push(Tag { id: row.id, name: row.name, }); } let bookmark_responses: Vec = bookmarks .into_iter() .map(|bookmark| { let tags = tags_by_bookmark .get(&bookmark.id) .cloned() .unwrap_or_default(); BookmarkResponse { id: bookmark.id, url: bookmark.url, title: bookmark.title, description: bookmark.description, created_at: bookmark.created_at, updated_at: bookmark.updated_at, tags, } }) .collect(); Ok(Json(PaginatedBookmarks { bookmarks: bookmark_responses, page, per_page, total, })) } async fn update_bookmark( State(state): State, Path(id): Path, Json(payload): Json, ) -> Result { let existing = sqlx::query_as!( Bookmark, "SELECT id, url, title, description, created_at, updated_at FROM bookmarks WHERE id = $1", id, ) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("bookmark with id '{}' not found", id)))?; let url = payload.url.unwrap_or(existing.url); let title = payload.title.unwrap_or(existing.title); let description = payload.description.or(existing.description); let bookmark = sqlx::query_as!( Bookmark, r#"UPDATE bookmarks SET url = $1, title = $2, description = $3, updated_at = NOW() WHERE id = $4 RETURNING id, url, title, description, created_at, updated_at"#, url, title, description, id, ) .fetch_one(&state.pool) .await?; let tags = sqlx::query_as!( Tag, r#"SELECT t.id, t.name FROM tags t INNER JOIN bookmark_tags bt ON bt.tag_id = t.id WHERE bt.bookmark_id = $1"#, bookmark.id, ) .fetch_all(&state.pool) .await?; Ok(Json(BookmarkResponse { id: bookmark.id, url: bookmark.url, title: bookmark.title, description: bookmark.description, created_at: bookmark.created_at, updated_at: bookmark.updated_at, tags, })) } async fn delete_bookmark( State(state): State, Path(id): Path, ) -> Result { let result = sqlx::query!("DELETE FROM bookmarks WHERE id = $1", id) .execute(&state.pool) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound(format!( "bookmark with id '{}' not found", id ))); } Ok(StatusCode::NO_CONTENT) } async fn attach_tags( State(state): State, Path(id): Path, Json(payload): Json>, ) -> Result { let exists = sqlx::query_scalar!("SELECT COUNT(*)::bigint FROM bookmarks WHERE id = $1", id) .fetch_one(&state.pool) .await? .unwrap_or(0) > 0; if !exists { return Err(AppError::NotFound(format!( "bookmark with id '{}' not found", id ))); } let mut tx = state.pool.begin().await?; let mut tags = Vec::new(); for name in payload { let tag = sqlx::query_as!( Tag, r#"INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id, name"#, name.trim().to_lowercase(), ) .fetch_one(&mut *tx) .await?; sqlx::query!( "INSERT INTO bookmark_tags (bookmark_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", id, tag.id, ) .execute(&mut *tx) .await?; tags.push(tag); } tx.commit().await?; Ok((StatusCode::CREATED, Json(tags))) } async fn detach_tag( State(state): State, Path((bookmark_id, tag_id)): Path<(Uuid, Uuid)>, ) -> Result { let result = sqlx::query!( "DELETE FROM bookmark_tags WHERE bookmark_id = $1 AND tag_id = $2", bookmark_id, tag_id, ) .execute(&state.pool) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("tag not attached to this bookmark".into())); } Ok(StatusCode::NO_CONTENT) } ``` Now, let me explain each handler. Pay attention to the SQL, the queries are where the learning happens in this article. ### The State ```rust #[derive(Clone)] struct AppState { pool: PgPool, } ``` Notice what changed from Part 2. Previously, `AppState` held `Arc>>`. Now it holds a `PgPool`. No `Arc`, no `RwLock`, no `HashMap`. The pool is inherently thread-safe (it uses `Arc` internally) and the database handles all concurrency. ### The From Impl for sqlx::Error ```rust impl From for AppError { fn from(err: sqlx::Error) -> Self { match err { sqlx::Error::RowNotFound => { AppError::NotFound("resource not found".into()) } _ => AppError::InternalError(format!("database error: {}", err)), } } } ``` This is new. In Parts 1 and 2, we had `From` impls for `PoisonError` on lock guards. Here we have one for `sqlx::Error`. This is what lets us use `?` after every database call, the error auto-converts into an `AppError`. `sqlx::Error::RowNotFound` is the specific error returned when `fetch_one` finds zero rows. Notice we check for it explicitly and return a 404 instead of a 500. All other SQLx errors, connection failures, constraint violations, serialization failures map to `InternalError` with the full error string logged to stderr. In production, you would log this with `tracing` and discard the raw error string from the client response for security. ### The main Function ```rust #[tokio::main] async fn main() { dotenvy::dotenv().ok(); let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let pool = PgPoolOptions::new() .max_connections(10) .connect(&database_url) .await .expect("failed to connect to database"); sqlx::migrate!("./migrations") .run(&pool) .await .expect("failed to run migrations"); let state = AppState { pool }; let app = Router::new() .route("/bookmarks", post(create_bookmark)) .route("/bookmarks", get(list_bookmarks)) .route("/bookmarks/{id}", get(get_bookmark)) .route("/bookmarks/{id}", put(update_bookmark)) .route("/bookmarks/{id}", delete(delete_bookmark)) .route("/bookmarks/{id}/tags", post(attach_tags)) .route("/bookmarks/{id}/tags/{tag_id}", delete(detach_tag)) .route("/healthz", get(healthz)) .with_state(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); } ``` `dotenvy::dotenv().ok()` loads the `.env` file into the process environment. The `.ok()` swallows the error if the file doesn't exist, which is fine for development. `sqlx::migrate!("./migrations").run(&pool)` compiles the migration SQL into the binary at build time and runs any unapplied migrations at startup. This means you can deploy a new version of your application with a new migration, and the schema updates automatically. No separate migration step in your deployment pipeline. The `Pool` is moved into `AppState`. After this, any handler can extract `State` and call `state.pool.begin()` or use `&state.pool` directly for single queries. ### The Create Bookmark Handler - Transactions ```rust async fn create_bookmark( State(state): State, Json(payload): Json, ) -> Result { if payload.url.trim().is_empty() || payload.title.trim().is_empty() { return Err(AppError::ValidationError( "url and title must not be empty".into(), )); } let mut tx = state.pool.begin().await?; let bookmark = sqlx::query_as!( Bookmark, r#"INSERT INTO bookmarks (url, title, description) VALUES ($1, $2, $3) RETURNING id, url, title, description, created_at, updated_at"#, payload.url, payload.title, payload.description, ) .fetch_one(&mut *tx) .await?; let mut tags = Vec::new(); if let Some(ref tag_names) = payload.tags { for name in tag_names { let tag = sqlx::query_as!( Tag, r#"INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id, name"#, name.trim().to_lowercase(), ) .fetch_one(&mut *tx) .await?; sqlx::query!( "INSERT INTO bookmark_tags (bookmark_id, tag_id) VALUES ($1, $2)", bookmark.id, tag.id, ) .execute(&mut *tx) .await?; tags.push(tag); } } tx.commit().await?; Ok(( StatusCode::CREATED, Json(BookmarkResponse { id: bookmark.id, url: bookmark.url, title: bookmark.title, description: bookmark.description, created_at: bookmark.created_at, updated_at: bookmark.updated_at, tags, }), )) } ``` This is the most important handler in the project. It demonstrates a **transaction**, a multi-step write that must either succeed entirely or leave the database unchanged. `state.pool.begin().await?` starts a new transaction. This sends a `BEGIN` to Postgres. Everything executed on `tx` from this point until `tx.commit().await?` (which sends a `COMMIT`) is part of one atomic unit. If any query inside the transaction fails for example, the tag insertion fails because of a constraint violation, the `?` operator returns an error from the handler. `tx` is dropped. When a `Transaction` is dropped without being committed, SQLx sends a `ROLLBACK` to Postgres, which undoes everything the transaction did. The bookmark that was inserted on line 164 is undone. The tags that were already inserted are undone. The database returns to the state it was in before `tx.begin()`. This is the answer to the problem from the beginning of this article. Without transactions, a failure halfway through "create bookmark and attach three tags" leaves an orphaned bookmark in the database with some tags attached and others not. With transactions, it's all or nothing. Let's trace the tag insertion logic step by step: 1. **`INSERT INTO tags ... ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id, name`** - This is an upsert. If a tag with this name already exists, Postgres returns the existing row. If it doesn't exist, Postgres inserts it and returns the new row. The `RETURNING` clause gives us the `id` and `name` of the row (whether it was newly inserted or already existed), so we know which `tag_id` to use in the junction table. The `ON CONFLICT DO UPDATE SET name = EXCLUDED.name` is a no-op update, it writes the existing value back to the row. We use it because we always need the tag's `id`. `ON CONFLICT DO NOTHING RETURNING ...` returns the inserted row only when a new row is created. If the conflict path is taken, it returns zero rows. Using `DO UPDATE ... RETURNING` guarantees that we receive the existing row in both cases. 2. **`INSERT INTO bookmark_tags ...`** - Creates the association between the bookmark and the tag in the junction table. No `ON CONFLICT` here, if the association already exists (because the client sent duplicate tags), the primary key constraint will reject it and the transaction rolls back. In production, you might handle this more gracefully with `ON CONFLICT DO NOTHING`. 3. **`tx.commit().await?`** - Commits the transaction. After this point, the bookmark and all its tags are durably stored. If the server crashes immediately after `commit()` returns, the data survives. Every query in the transaction uses `&mut *tx` (a mutable reference to the transaction) instead of `&state.pool`. This is critical, queries on the pool use separate connections, which are not part of the transaction. If you accidentally used `&state.pool` for one of the tag insertions, that tag would be inserted outside the transaction and would survive a rollback. > **What happens if the server crashes between two queries in the transaction?** The TCP connection to Postgres drops. Postgres detects the lost connection and automatically rolls back the transaction. The partial state is never persisted. ### The Get Bookmark Handler - Two Queries, Two Tables ```rust async fn get_bookmark( State(state): State, Path(id): Path, ) -> Result { let bookmark = sqlx::query_as!( Bookmark, "SELECT id, url, title, description, created_at, updated_at FROM bookmarks WHERE id = $1", id, ) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("bookmark with id '{}' not found", id)))?; let tags = sqlx::query_as!( Tag, r#"SELECT t.id, t.name FROM tags t INNER JOIN bookmark_tags bt ON bt.tag_id = t.id WHERE bt.bookmark_id = $1"#, bookmark.id, ) .fetch_all(&state.pool) .await?; Ok(Json(BookmarkResponse { id: bookmark.id, url: bookmark.url, title: bookmark.title, description: bookmark.description, created_at: bookmark.created_at, updated_at: bookmark.updated_at, tags, })) } ``` Two queries on two separate connections from the pool. First, fetch the bookmark by its `Path(id)`, notice `Path` deserializes the path segment directly into a `Uuid`, which is more type-safe than `Path`. Second, fetch all tags associated with that bookmark via the junction table. `fetch_optional` returns `Option`. If the row exists, we get `Some`. If it doesn't, we get `None`. The `.ok_or_else(|| ...)` converts `None` into an `AppError::NotFound`. If `fetch_optional` itself fails (network error, connection timeout), the `?` converts the `sqlx::Error` into `AppError::InternalError` via the `From` impl. The tag query joins `tags` with `bookmark_tags` to find all tags attached to this bookmark. `INNER JOIN` means only tags that actually have an association are returned. If the bookmark has no tags, `fetch_all` returns an empty `Vec`, no error, just an empty tags list in the response. ### The List Bookmarks Handler - Pagination, Tag Filtering, and the N+1 Trap This is the longest handler. Let's break it into three parts: pagination, the conditional query, and tag fetching. **Pagination:** ```rust let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * per_page; ``` `.max(1)` prevents negative or zero page numbers. `.clamp(1, 100)` caps the page size between 1 and 100 - requesting 1000 items per page is either a bug or an abuse vector. `offset` is calculated from `(page - 1) * per_page`. Page 1, 20 per page gives offset 0. Page 2 gives offset 20. This is standard cursor-less pagination. **The conditional query - tagged vs untagged:** ```rust let (bookmarks, total): (Vec, i64) = if let Some(ref tag_name) = params.tag { // Tagged: count and fetch bookmarks with a specific tag let total = sqlx::query_scalar!( r#"SELECT COUNT(DISTINCT b.id)::bigint FROM bookmarks b INNER JOIN bookmark_tags bt ON bt.bookmark_id = b.id INNER JOIN tags t ON t.id = bt.tag_id WHERE t.name = $1"#, tag_name.trim().to_lowercase(), ) .fetch_one(&state.pool) .await? .unwrap_or(0); let bookmarks = sqlx::query_as!( Bookmark, r#"SELECT DISTINCT b.id, b.url, b.title, b.description, b.created_at, b.updated_at FROM bookmarks b INNER JOIN bookmark_tags bt ON bt.bookmark_id = b.id INNER JOIN tags t ON t.id = bt.tag_id WHERE t.name = $1 ORDER BY b.created_at DESC LIMIT $2 OFFSET $3"#, tag_name.trim().to_lowercase(), per_page, offset, ) .fetch_all(&state.pool) .await?; (bookmarks, total) } else { // Untagged: count and fetch all bookmarks let total = sqlx::query_scalar!("SELECT COUNT(*)::bigint FROM bookmarks") .fetch_one(&state.pool) .await? .unwrap_or(0); let bookmarks = sqlx::query_as!( Bookmark, r#"SELECT id, url, title, description, created_at, updated_at FROM bookmarks ORDER BY created_at DESC LIMIT $1 OFFSET $2"#, per_page, offset, ) .fetch_all(&state.pool) .await?; (bookmarks, total) }; ``` Two separate query blocks, one with tag filtering, one without. Conditionally branching on `params.tag` is simpler than building a dynamic query string, and it avoids SQL injection entirely. In the tagged path, the query joins `bookmarks` → `bookmark_tags` → `tags` and filters by tag name. `DISTINCT` prevents duplicate bookmarks if a bookmark has multiple tags (the join would otherwise produce one row per tag per bookmark). The count query counts distinct bookmark IDs for the same reason, without `DISTINCT`, the count would include duplicates. `query_scalar!` is a new macro. It's for queries that return a single column, like `COUNT(*)` or `SELECT name FROM ... WHERE id = $1`. It deserializes directly into a Rust scalar type (`i64`, `String`, etc.) instead of a struct. **The tag fetching - avoiding the N+1 trap:** Here is where this handler differs from a naive approach. A naive approach would be: ```rust // DON'T DO THIS - the N+1 trap for bookmark in &bookmarks { let tags = sqlx::query_as!(Tag, "... WHERE bt.bookmark_id = $1", bookmark.id) .fetch_all(&state.pool) .await?; // ... } ``` This is the **N+1 query problem**. If the page has 20 bookmarks, you make 1 query for the bookmarks themselves, then 20 more queries for the tags (one per bookmark). That's 21 queries total. On the next page, another 21. At scale, this multiplies your database load by the page size and it's easy to write by accident because each lookup feels natural in a loop. The fix: **fetch all tags for all bookmarks on the current page in a single query**, then group them in application code: ```rust let bookmark_ids: Vec = bookmarks.iter().map(|b| b.id).collect(); #[derive(Debug, sqlx::FromRow)] struct TagWithBookmark { id: Uuid, name: String, bookmark_id: Uuid, } let all_tags: Vec = if bookmark_ids.is_empty() { Vec::new() } else { sqlx::query_as!( TagWithBookmark, r#"SELECT t.id, t.name, bt.bookmark_id FROM tags t INNER JOIN bookmark_tags bt ON bt.tag_id = t.id WHERE bt.bookmark_id = ANY($1)"#, &bookmark_ids as &[Uuid], ) .fetch_all(&state.pool) .await? }; ``` `WHERE bt.bookmark_id = ANY($1)` with the slice `&bookmark_ids as &[Uuid]` tells Postgres "give me all tag associations for any of these bookmark IDs." Postgres uses the `idx_bookmark_tags_bookmark_id` index for each ID in the list and returns all matching rows in a single result set. `TagWithBookmark` is a helper struct used only inside this handler. It includes `bookmark_id` so we know which bookmark each tag belongs to when grouping. The `if bookmark_ids.is_empty()` check simply avoids making an unnecessary database query. `ANY()` works correctly with an empty array, but since we already know there are no bookmark IDs, we can return an empty vector immediately. Then we group by bookmark ID: ```rust let mut tags_by_bookmark: HashMap> = HashMap::new(); for row in all_tags { tags_by_bookmark .entry(row.bookmark_id) .or_default() .push(Tag { id: row.id, name: row.name }); } ``` And finally map each bookmark to its response, looking up tags from the HashMap: ```rust let bookmark_responses: Vec = bookmarks .into_iter() .map(|bookmark| { let tags = tags_by_bookmark .get(&bookmark.id) .cloned() .unwrap_or_default(); BookmarkResponse { /* ... */ tags } }) .collect(); ``` The total number of queries is now 3 (bookmarks, count, tags), regardless of page size. This is the difference between an API that handles 1000 concurrent users and one that falls over at 10. ### The Update Bookmark Handler ```rust async fn update_bookmark( State(state): State, Path(id): Path, Json(payload): Json, ) -> Result { let existing = sqlx::query_as!( Bookmark, "SELECT id, url, title, description, created_at, updated_at FROM bookmarks WHERE id = $1", id, ) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("bookmark with id '{}' not found", id)))?; let url = payload.url.unwrap_or(existing.url); let title = payload.title.unwrap_or(existing.title); let description = payload.description.or(existing.description); let bookmark = sqlx::query_as!( Bookmark, r#"UPDATE bookmarks SET url = $1, title = $2, description = $3, updated_at = NOW() WHERE id = $4 RETURNING id, url, title, description, created_at, updated_at"#, url, title, description, id, ) .fetch_one(&state.pool) .await?; // ... fetch tags and return } ``` A partial update pattern. The client sends only the fields it wants to change. Fields not present in the request default to their existing values from the database. `payload.url.unwrap_or(existing.url)` takes the client-supplied value if present, otherwise falls back to the existing value. `payload.description.or(existing.description)` does the same for `Option` , notice `or()` instead of `unwrap_or()`, since both are `Option` types. `updated_at = NOW()` sets the timestamp to the current server time. This is done in SQL rather than Rust because the database clock is the single source of truth for when a row was last modified. If you set it in Rust, clock skew between application servers could cause inconsistent timestamps. ### The Delete Bookmark Handler - Cascade ```rust async fn delete_bookmark( State(state): State, Path(id): Path, ) -> Result { let result = sqlx::query!("DELETE FROM bookmarks WHERE id = $1", id) .execute(&state.pool) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound(format!( "bookmark with id '{}' not found", id ))); } Ok(StatusCode::NO_CONTENT) } ``` A single `DELETE` statement. The `ON DELETE CASCADE` foreign key on `bookmark_tags.bookmark_id` ensures that deleting a bookmark also deletes all rows in the junction table. Without `CASCADE`, you would need to delete from `bookmark_tags` first two queries, or a transaction. `CASCADE` handles it in one. `rows_affected()` returns the number of rows that were modified. If it's 0, the bookmark didn't exist and we return 404. ### The Attach Tags Handler - Transactional Tag Creation ```rust async fn attach_tags( State(state): State, Path(id): Path, Json(payload): Json>, ) -> Result { // Check bookmark exists let exists = sqlx::query_scalar!("SELECT COUNT(*)::bigint FROM bookmarks WHERE id = $1", id) .fetch_one(&state.pool) .await? .unwrap_or(0) > 0; if !exists { return Err(AppError::NotFound(...)); } let mut tx = state.pool.begin().await?; let mut tags = Vec::new(); for name in payload { // Upsert tag, insert into bookmark_tags // ... } tx.commit().await?; Ok((StatusCode::CREATED, Json(tags))) } ``` Similar tag creation logic to `create_bookmark`, but for an existing bookmark. The existence check happens before the transaction starts, no point opening a transaction if the bookmark doesn't exist. The `ON CONFLICT DO NOTHING` on the `bookmark_tags` insert is important here: if the client sends a tag that's already attached, the insert is silently skipped instead of failing with a unique constraint violation. Without `DO NOTHING`, attaching `["rust", "rust"]` would cause the second insert to fail and roll back the entire transaction, losing the first tag. ### The Detach Tag Handler - Tuple Path Parameters ```rust async fn detach_tag( State(state): State, Path((bookmark_id, tag_id)): Path<(Uuid, Uuid)>, ) -> Result { let result = sqlx::query!( "DELETE FROM bookmark_tags WHERE bookmark_id = $1 AND tag_id = $2", bookmark_id, tag_id, ) .execute(&state.pool) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("tag not attached to this bookmark".into())); } Ok(StatusCode::NO_CONTENT) } ``` `Path<(Uuid, Uuid)>` is a new Axum trick. The route is `/bookmarks/{id}/tags/{tag_id}`. Axum deserializes the two path segments `{id}` and `{tag_id}` into a tuple `(Uuid, Uuid)`. This is cleaner than writing `Path(id): Path, Path(tag_id): Path` as separate extractor parameters, though both work. The tuple pattern works for up to 16 segments and saves a line of function signature. > **Why not use `Path` for both?** You can `async fn detach_tag(Path(bookmark_id): Path, Path(tag_id): Path)` but Axum's `Path` extractor can only appear once per handler since it's a `FromRequestParts` extractor limited to capturing a single path pattern. The tuple syntax `Path((a, b))` captures both in one extractor. ## Understanding Transactions Transactions are the single most important database concept beyond basic CRUD. Here is the exact lifecycle of the `create_bookmark` transaction: 1. **`state.pool.begin().await?`** - SQLx acquires a connection from the pool and sends `BEGIN` to Postgres. Postgres marks this connection as "in a transaction." All subsequent queries on this connection are part of the transaction. 2. **`INSERT INTO bookmarks ...`** - The bookmark is inserted, but it is not visible to any other connection. Other users querying `bookmarks` will not see this row until the transaction commits. This is Postgres's default isolation level, `READ COMMITTED`, at work. 3. **`INSERT INTO tags ... RETURNING ...`** - Each tag is upserted. The `RETURNING` clause returns the tag's `id` and `name`, which we need for the junction table insert. The upsert uses the transaction's snapshot of the database — if another concurrent transaction creates a tag with the same name at the same moment, Postgres would block one of them (because of the `UNIQUE` constraint) and the second would see the first's row. 4. **`INSERT INTO bookmark_tags ...`** - The junction table row is inserted, linking the bookmark and the tag. 5. **`tx.commit().await?`** - SQLx sends `COMMIT` to Postgres. Postgres writes all the transaction's changes to the write-ahead log (WAL), making them durable. At this moment, the changes become visible to all other connections. The pooled connection is returned to the pool. If any step fails, the `?` operator returns an `Err`, `tx` is dropped. SQLx's `Transaction` destructor sends `ROLLBACK` to Postgres. Postgres undoes all changes made during the transaction as if they never happened. No orphaned bookmarks. No partial tag attachments. ### What If Two Transactions Run at the Same Time? The `name TEXT NOT NULL UNIQUE` constraint on `tags` is the key. If two concurrent requests both try to create a tag called `rust`: 1. Transaction A inserts `rust` into `tags`. Postgres acquires an exclusive lock on the new row. 2. Transaction B also tries to insert `rust`. Because of the `UNIQUE` constraint, Postgres blocks B until A commits or rolls back. 3. If A commits, B's insert fails with a unique violation but our query uses `ON CONFLICT (name) DO UPDATE`, so instead of failing, it returns the existing `rust` row that A inserted. B proceeds with A's tag ID. 4. If A rolls back, B's insert succeeds because the conflicting row no longer exists. Without `ON CONFLICT`, B's insert would fail, the `?` would propagate the error, and B's entire transaction would roll back. The bookmark B was creating would be lost. The upsert pattern is a concurrency-safe way to "get or create" a shared resource like a tag. ## Isolation Levels - A Brief Look Postgres's default isolation level is `READ COMMITTED`. You can see it if you run `SHOW default_transaction_isolation;` in `psql`. Here is what it means, and what the alternatives are: ### READ COMMITTED (the default) Each statement in a transaction sees only data that was committed before the statement began, not before the transaction began. This means: - **No dirty reads:** You never see uncommitted data from another transaction. - **Non-repeatable reads are possible:** If transaction A reads a row, transaction B updates and commits that row, then A reads it again, A sees B's update. The row changed between reads within A's transaction. This is sufficient for most applications. In our bookmark manager, the only write that involves multiple tables is `create_bookmark` (bookmark + tags), and that's wrapped in a transaction. Since the bookmark, tags, and their associations are all created inside a single transaction, other transactions do not observe any of those changes until the transaction commits. ### REPEATABLE READ Each statement in a transaction sees only data that was committed before the **transaction** began. The snapshot is frozen at transaction start. Non-repeatable reads cannot happen. This is stronger isolation, but it can lead to serialization failures, if two transactions modify the same row, one must be retried. ### SERIALIZABLE The strongest level. Transactions run as if they were executed one after another, even though they actually run concurrently. Postgres detects conflicts and forces one transaction to fail with a serialization failure, which the application must retry. This is the safest level, but it has the highest overhead and requires retry logic in your application code. For a bookmark manager, `READ COMMITTED` (the default) is correct. For a banking application, you want `SERIALIZABLE` or at least `REPEATABLE READ`. The isolation level is set per transaction: ```rust let mut tx = pool.begin().await?; sqlx::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") .execute(&mut *tx) .await?; ``` You probably won't need this in practice, but knowing what the options mean and why Postgres defaults to `READ COMMITTED` is table stakes for backend engineering. ## Running the Project Make sure Docker is running, then: ``` docker compose up -d cargo sqlx prepare cargo run ``` You should see: ``` Listening on http://127.0.0.1:3000 ``` ### Create a Bookmark ``` curl -X POST http://localhost:3000/bookmarks \ -H "Content-Type: application/json" \ -d '{ "url": "https://www.rust-lang.org", "title": "Rust Programming Language", "description": "The official Rust website", "tags": ["rust", "programming"] }' ``` Response: ```json { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "url": "https://www.rust-lang.org", "title": "Rust Programming Language", "description": "The official Rust website", "created_at": "2026-07-12T17:00:00.000000Z", "updated_at": "2026-07-12T17:00:00.000000Z", "tags": [ {"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "rust"}, {"id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "name": "programming"} ] } ``` The bookmark and both tags were created in a single transaction. If the server crashed after inserting the bookmark but before inserting the `programming` tag, the entire operation rolls back, no orphaned bookmark. ### Get a Bookmark ``` curl http://localhost:3000/bookmarks/a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` ### List All Bookmarks ``` curl http://localhost:3000/bookmarks ``` Response includes pagination metadata: ```json { "bookmarks": [...], "page": 1, "per_page": 20, "total": 1 } ``` ### List Bookmarks with Pagination ``` curl "http://localhost:3000/bookmarks?page=2&per_page=10" ``` ### Filter by Tag ``` curl "http://localhost:3000/bookmarks?tag=rust" ``` This returns only bookmarks that have the `rust` tag. ### Update a Bookmark ``` curl -X PUT http://localhost:3000/bookmarks/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "Content-Type: application/json" \ -d '{"title": "Rust Lang (Updated)"}' ``` Only the `title` field is updated. `url` and `description` retain their existing values. `updated_at` is set to `NOW()` by the database. ### Attach Tags to an Existing Bookmark ``` curl -X POST http://localhost:3000/bookmarks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/tags \ -H "Content-Type: application/json" \ -d '["web", "systems"]' ``` Upserts the tags if they don't exist, inserts the associations, returns the full tag objects. If a tag is already attached, `ON CONFLICT DO NOTHING` skips it silently. ### Detach a Tag ``` curl -X DELETE http://localhost:3000/bookmarks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/tags/b2c3d4e5-f6a7-8901-bcde-f12345678901 ``` Returns 204 No Content on success, 404 if the tag wasn't attached. ### Delete a Bookmark ``` curl -X DELETE http://localhost:3000/bookmarks/a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` Returns 204. The bookmark and all its tag associations are removed. `ON DELETE CASCADE` handles the junction table rows. ### Test Validation ``` curl -X POST http://localhost:3000/bookmarks \ -H "Content-Type: application/json" \ -d '{"url": "", "title": ""}' ``` Response: ```json {"error": "url and title must not be empty"} ``` Status code: 400. ### Test Not Found ``` curl http://localhost:3000/bookmarks/00000000-0000-0000-0000-000000000000 ``` Response: ```json {"error": "bookmark with id '00000000-0000-0000-0000-000000000000' not found"} ``` Status code: 404. ## What We Skipped - **The `thiserror` crate**: We wrote `From` impls manually to show the mechanism. In practice, `thiserror` derives these for you compactly. Use it in your own projects. - **Compile-time query checking without a running database**: The `.sqlx/` directory generated by `cargo sqlx prepare` is the offline query cache. Commit it. CI builds won't need a database. We covered this in the setup. - **Full-text search**: Searching bookmarks by URL or title substring would require a `tsvector` and GIN index. That's a different problem from tag-based filtering. - **User accounts**: Bookmarks belong to everyone. Part 6 adds authentication and user-scoped data. - **Orphaned tags**: If all bookmarks with a given tag are deleted, the tag row remains in the `tags` table with no associations. This is harmless (takes negligible space) but a production system might periodically clean up unused tags with a background job (Part 9). - **Soft deletes**: `DELETE` is permanent. A real application might use a `deleted_at` column and a `WHERE deleted_at IS NULL` filter on all queries. This is an application-level decision, not a database one. ## Conclusion You now have a solid foundation for building data-driven Axum applications. In the next article, we'll build on it by introducing caching and idempotency, two techniques that make APIs faster, more resilient, and safer to retry. See you soon # Handbook > My notes and thoughts while reading the handbook from Epictetus - The Complete Works book. Source: https://blog.sheerluck.dev/posts/book-notes/epictetus-the-complete-works/epictetus-the-complete-works-handbook/ · Markdown: https://blog.sheerluck.dev/posts/book-notes/epictetus-the-complete-works/epictetus-the-complete-works-handbook/index.md > **Reading this book?** > > These notes are based on **The Complete Works of Epictetus**. > > If you'd like to read the same edition, you can get it here: > > 👉 **[The Complete Works of Epictetus (Amazon)](https://www.amazon.in/Complete-Works-Handbook-Discourses-Fragments/dp/022676947X?&linkCode=ll2&tag=blog048bf-21&linkId=1da38e6b092c6a93a423d190d5702856&ref_=as_li_ss_tl)** > > *Disclosure: This is an Amazon affiliate link. If you purchase through it, I may earn a small commission at no extra cost to you.* ## 01 - What is Actually Mine? > Some things are up to us and some are not. Up to us are judgement, inclination, desire, aversion - in short, whatever is our own doing. Not up to us are our bodies, possessions, reputations, public offices - in short, whatever isn’t our own doing To me, it seems reasonable what Epictetus said. Things like judgments, our desire or things that we want to avoid are completely under our control. To give you some examples: Let’s say someone insults you. Then it is up to your judgement to either ignore it, react to it, calmly handle it or whatever. I think for the rest of the 3 things (inclination, desire and aversion), these come after we made a judgement For things that are not up to us. Clearly, reputation depends on others, what other people think of you or you opinions and so on. For bodies, some other people or an external event can impact your body like an accident even if you were just sitting still or doing nothing. > If you regard things that are naturally enslaved as free, if you regard things that are not yours are yours, you’ll be obstructed, dejected, and troubled, and you’ll blame both gods and men. For example, if we try to think that we have control over getting a job, clearing some exam, getting that promotion, getting certain viewers or followers count. These things are not up to us, hence we are moving away from reality and in frustration we will blame god or other people. > If you regard as yours only what is yours, and as not yours what is not yours, which is the way things are in reality, no one will ever constrain you, no one will impede you, you’ll blame no one, you’; reproach no one, you’ll never act reluctantly, no one will harm you, and you’ll have no enemies, because you’ll never be harmed Epictetus clearly says that this is actually difficult to do. So we need to practice this as much as we can. So every time, we face a situation, we should tell ourselves to examine if the situation or event is under our control or not. If not, then leave it and no need to do anything but if its up to us, then make judgement and take action towards it # The Basic Laws of Human Stupidity > My notes and thoughts while reading The Basic Laws of Human Stupidity. Source: https://blog.sheerluck.dev/posts/book-notes/the-basic-laws-of-human-stupidity/ · Markdown: https://blog.sheerluck.dev/posts/book-notes/the-basic-laws-of-human-stupidity/index.md This is a collection of my notes while reading **The Basic Laws of Human Stupidity** by Carlo M. Cipolla. It is not a review or a comprehensive summary. My goal is simply to document the ideas that stood out to me and my understanding of them. ## Foreword by Nassim Nicholas Taleb This foreword made me chuckle quite a bit (though I admit I laugh easily). I recommend reading it before continuing. > **Note:** Despite the name, these "laws" are not scientific laws. The book is a humorous essay rather than an empirical study, so it's best read as a thought-provoking framework rather than established scientific fact. ## Introduction The author says that human life has always had problems. Struggles are a part of life for every living being. But humans have one extra problem: the harm caused by other humans. He believes that a lot of this harm comes from stupid people. They are not organized and have no leader, but their actions still create a big negative impact on society. The author also says this book is not meant to be negative or hopeless. Its purpose is to understand stupidity better so we can recognize it and reduce the damage it causes. ## The First Basic Law > Always and inevitably everyone underestimates the number of stupid individuals in circulation Author says that no matter how high our estimate is, the actual number is usually higher. We often think someone is intelligent, but later they do something surprisingly stupid. We also keep running into stupid people at the worst possible time and place. Because of this, the author says it is impossible to give an exact number of stupid people in society. Instead, he uses the symbol $\sigma$ (sigma) to represent their fraction in a population ## The Second Basic Law > The probability that a certain person be stupid is independent of any other characteristic of that person According to the author, stupidity is not linked to education, intelligence, wealth, social status, gender, race or profession. It is something people are born with and is spread evenly across every group in society. The author claims that every group be it workers, students, professors and even Nobel Prize winners has roughly the same proportion of stupid people. He uses this to argue that no group is free from stupidity. So, no matter where you go or who you spend time with, you should expect to find the same percentage of stupid people. Combined with the first law, this also means that the number of stupid people will always be more than you expect ## Technical Interlude Before introducing the third law, the author explains a simple way to understand human actions. Every action has two outcomes: one for ourselves and one for others. We may gain or lose from an action and at the same time other people may also gain or lose. Even choosing not to act can affect others because it can create a missed opportunity. To explain this, the author uses a graph. The **X-axis** shows our gain or loss and the **Y-axis** shows the other person’s gain or loss. This helps classify different kinds of human behaviour. The author also points out that we should judge another person’s gain or loss from **their perspective**, not our own. What feels like a benefit to us may actually be a loss for someone else. ## The Third (And Golden) Basic Law > A stupid person is a person who causes losses to another person or to a group of persons while himself deriving no gain and even possibly incurring loss Before this law, the author divides people into four types based on how their actions affect themselves and others: - Intelligent: Their actions benefit both themselves and others. - Helpless: Their actions benefit others but cause a loss to themselves - Bandit: Their actions benefit themselves by causing a loss to others - Stupid: Their actions case a loss to others without bringing any benefit to themselves and they may even end up harming themselves ![types of people](/images/tblohs-first.png) The third law defines a **stupid person** as someone who causes a loss to others while gaining nothing from it and sometimes even harming themselves in the process. This is what makes stupidity different from selfishness or crime. A bandit acts for personal gain, but a stupid person creates a damage without any benefit. According to the author, this makes stupid people the most unpredictable and dangerous type. ## Frequency Distribution of Human Types The author says that most people are **not consistent**. A person may act intelligently in one situation and helplessly or selfishly in another. So, people are classified based on their **overall pattern of behaviour**, not on a single action. The only exception is **stupid people**, who tend to behave stupidly in almost every situation. The author also explains that **bandits** are not all the same. A *perfect bandit* gains exactly as much as others lose. But most bandits either gain more than the damage they cause (making them closer to intelligent people) or more commonly, gain less than the damage they cause(making them closer to stupidity). Unlike bandits, **stupid people are concentrated in one category**. Most of them repeatedly harm others without gaining anything themselves. Some even harm themselves while hurting others. The author calls these people **super-stupid**, as they create losses for everyone involved, including themselves. ## Stupidity and Power The author says that not all stupid people are equally dangerous. Some cause only small problems, while others create huge damage that affects entire societies. Their ability to cause harm depends on two things: how naturally stupid they are and how much power they have. ![stupidity and power](/images/tblohs-second.png) A stupid person in an important position such as a politician, military leader, bureaucrat or other authority can cause far greater damage than an ordinary person. The author also explains that throughout history, different systems like class, case, religion, political parties, bureaucracy and even democracy have allowed stupid people to reach positions of power. Since stupidity exists in every group of people, some of these individuals will always end up in a leadership role, increasing their ability to harm others. ## The Power of Stupidity The author explains that the real danger of stupid people is their **unpredictability**. We can usually understand the motives of selfish or dishonest people because they act for personal gain. Their actions are rational, so we can often predict them and prepare for a response. A stupid person is different. They may harm others without any reason, benefit or clear plan. Because their actions do not follow logic, they are almost impossible to predict. This makes it difficult to defend against them or respond effectively. According to the author, this unpredictability is what makes stupid people more dangerous than people who act out of self-interest. ## The Fourth Basic Law > Non-stupid people always underestimate the damaging power of stupid individuals. In particular non-stupid people constantly forget that at all times and places and under any circumstances to deal and/or associate with stupid people infallibly turns out to be a costly mistake What is more surprising is that even intelligent people and bandits make the same mistake. They often think they can predict, control or even use a stupid person for their own benefit. According to the author, this never works because stupid people do not act logically. Their behaviour is unpredictable, making them impossible to control. Trying to work with or take advantage of a stupid person usually ends up causing losses. ## Macro Analysis and The Fifth Basic Law > A stupid person is the most dangerous type of person. A stupid person is more dangerous than a bandit A bandit harms others for personal gain, so wealth is simply transferred from one person to another. Society as a whole does not lose, it only changes who has the wealth. A stupid person is different. They harm others without gaining anything themselves. This creates a net loss for everyone, making society poorer overall. The author also explains that every society has about the same proportion of stupid people. The difference between a successful society and a failing one is how much influence these people have. Successful societies have enough intelligent people to limit damage caused by stupidity. Failing societies allow stupid people to become more active and influential, while the number of intelligent people decreases. As a result, the destructive effects of stupidity grow, leading to the decline of society. ## My Thoughts I think the author's framework is useful because it forces us to think about outcomes rather than intentions. Whether the framework is literally true is less important than whether it helps explain situations we've observed. I don't agree that stupidity is necessarily an innate trait distributed equally across all groups because the book doesn't provide evidence for that claim. However, I do think the third law offers an interesting way to distinguish irrational harmful behaviour from ordinary selfishness. # Building Breakout in Bevy: Step by Step > Build Breakout game step by step from scratch using the Bevy game engine in Rust Source: https://blog.sheerluck.dev/posts/gamedev/bevy/build-breakout-in-bevy-step-by-step/ · Markdown: https://blog.sheerluck.dev/posts/gamedev/bevy/build-breakout-in-bevy-step-by-step/index.md In this post, we are going to build **Breakout**, the classic arcade game where you control a paddle, bounce a ball, and destroy a grid of bricks. If you have been following the series from Pong and Snake, you already know the ECS basics, queries, resources, timers, and game states. Now we put it all together into a complete game with particles, lives, and a proper game over loop. We will build the game incrementally. After every section, you can `cargo run` and see something new on screen. This article won't teach you anything new, the motive of this article is to reinforce what you learnt in the previous 2 articles of this series. Get the full source code from [here](https://github.com/MrSheerluck/breakout-bevy). ![breakout-still-image](/images/breakout-still-image.png) ### What We Are Building If you have not played Breakout before, here is how it works. A paddle sits near the bottom of the screen. A ball bounces around the play area. Bricks are arranged in rows at the top. Your goal is to destroy all the bricks by bouncing the ball into them. If the ball falls past the paddle, you lose a life. Lose all three lives and it is game over. We will build this using colored rectangles for everything, no sprite sheets or image files needed. The bricks will have different colors per row. The ball will leave a particle burst every time it destroys a brick. Score and lives will be displayed as text. ### 1. A Window on Screen Open your terminal and create a new Rust project: ```bash cargo new bevy_breakout cd bevy_breakout ``` Open `Cargo.toml` and replace its contents with: ```toml [package] name = "bevy_breakout" version = "0.1.0" edition = "2024" [dependencies] bevy = { version = "0.18", features = ["wav"] } rand = "0.8" ``` We add `rand` for randomizing particle directions later. The `wav` feature enables Bevy audio support for the future. Now open `src/main.rs` and write our first version, a window with a dark background, nothing else yet: ```rust use bevy::prelude::*; use bevy::window::WindowResolution; const WINDOW_WIDTH: f32 = 800.0; const WINDOW_HEIGHT: f32 = 600.0; fn main() { App::new() .add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { title: "Breakout".into(), resolution: WindowResolution::new(WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32), ..default() }), ..default() })) .insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.08))) .run(); } ``` Run it: ```bash cargo run ``` You should see a dark 800×600 window titled "Breakout". Nothing moves yet but it compiles and runs, which means Bevy is set up correctly. Now we add the paddle. ### 2. The Paddle We need a paddle that the player can move left and right. Add these constants and the `Paddle` marker component **above** `fn main()`: ```rust const PADDLE_WIDTH: f32 = 100.0; const PADDLE_HEIGHT: f32 = 16.0; const PADDLE_SPEED: f32 = 600.0; const PADDLE_Y: f32 = -250.0; #[derive(Component)] struct Paddle; ``` Now add the `setup` function that spawns the camera and the paddle, and the `move_paddle` system that handles keyboard input. Place both above `fn main()`: ```rust fn setup(mut commands: Commands) { commands.spawn(Camera2d); commands.spawn(( Paddle, Sprite::from_color(Color::srgb(0.6, 0.8, 1.0), Vec2::new(PADDLE_WIDTH, PADDLE_HEIGHT)), Transform::from_xyz(0.0, PADDLE_Y, 0.0), )); } fn move_paddle( keyboard: Res>, time: Res